diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f522797de9..dd1292d05e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,14 @@ jobs: - name: Lint run: pnpm lint + - name: i18n contracts + run: pnpm check:i18n:contracts + + - name: Web production build + run: pnpm build:web + env: + NODE_OPTIONS: "--max-old-space-size=6144" + - name: Unit tests run: pnpm run test 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/docs/frontend-ui-audit-2026-08-21/ChatPanelChrome.md b/docs/frontend-ui-audit-2026-08-21/ChatPanelChrome.md new file mode 100644 index 0000000000..2d79ccd6db --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-21/ChatPanelChrome.md @@ -0,0 +1,35 @@ +# Frontend UI Audit — ChatPanelChrome + +**File:** `src/engines/ChatPanel/header/ChatPanelChrome.tsx` (95 LOC) +**Date:** 2026-08-21 +**Auditor:** Codex PR #850 CI repair + +## D1 — Raw HTML vs Design System + +No findings. The component delegates interaction to `CollapsedSidebarButton` and renders only layout containers. + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ---------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 54 | `pr-[7px]` | keep with reason | The exact 7 px trailing alignment is already the established detail/header geometry in `DETAIL_PANEL_TOKENS` and `GitHubPrPanelView`; changing only this shared chrome would create a one-pixel mismatch. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| 48 | computed `height` | keep with reason | The value is selected from named `CHAT_PANEL_*_HEIGHT_PX` layout constants so desktop and remote hosts retain initialization parity. | — | + +## D4 — Accessibility + +No findings. The decorative glass layer is explicitly hidden from assistive technology. + +## D5 — Visual Patterns Observed + +- The tab row and published header are intentionally centralized here for desktop and read-only transcript hosts; no third duplicate remains in the audited scope. + +## Summary + +- 0 fixes recommended +- 2 kept with documented reason +- 0 abstract candidates (>= 3 occurrences) diff --git a/docs/frontend-ui-audit-2026-08-21/RemoteSessionChatPanelSurface.md b/docs/frontend-ui-audit-2026-08-21/RemoteSessionChatPanelSurface.md new file mode 100644 index 0000000000..19ad88fde9 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-21/RemoteSessionChatPanelSurface.md @@ -0,0 +1,35 @@ +# Frontend UI Audit — RemoteSessionChatPanelSurface + +**File:** `src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx` (194 LOC) +**Date:** 2026-08-21 +**Auditor:** Codex PR #850 CI repair + +## D1 — Raw HTML vs Design System + +No findings. Interactive presentation uses the existing `SelectorPill` and `SessionReadOnlyBar` components. + +## D2 — Arbitrary Tailwind Value vs Token + +No findings. + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| 80 | agent icon `size: 14` | keep with reason | The compact icon is shared by the published header and selector pill and matches the established selector icon geometry. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | ---------------- | +| 154 | disabled `SelectorPill` | keep with reason | The component supplies an explicit localized `ariaLabel`; the icon is decorative and marked `aria-hidden`. | — | + +## D5 — Visual Patterns Observed + +- Read-only transcript chrome composes existing shared primitives; no repeated pattern requiring a new abstraction was found. + +## Summary + +- 0 fixes recommended +- 2 kept with documented reason +- 0 abstract candidates (>= 3 occurrences) diff --git a/docs/frontend-ui-audit-2026-08-21/RemoteSessionWorkspaceSurface.md b/docs/frontend-ui-audit-2026-08-21/RemoteSessionWorkspaceSurface.md new file mode 100644 index 0000000000..9ce9f719fa --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-21/RemoteSessionWorkspaceSurface.md @@ -0,0 +1,45 @@ +# Frontend UI Audit — RemoteSessionWorkspaceSurface + +**File:** `src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx` (228 LOC) +**Date:** 2026-08-21 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| -------- | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------- | ---------------- | +| 122, 183 | Progress indicators | keep with reason | Both states use the shared `ProgressBar`; no raw interactive control duplicates a design-system component. | — | +| 208 | Retry action | keep with reason | The retry action uses the shared `Button` with the compact tertiary variant. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ------------------------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------- | ---------------- | +| — | No arbitrary CSS-variable or raw-color utilities | keep with reason | New surfaces use the existing `bg-bg-2`, `text-text-*`, `border-border-2`, and `bg-danger-1` tokens. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| ---- | ----------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 194 | `text-[11px]` progress detail | keep with reason | This matches the existing 11px WorkStation status-row typography, including the same-file refresh banner and `SectionStatusRow`; the current typography scale has no equivalent semantic utility. | — | +| 203 | `text-[11px]` refresh banner | keep with reason | Pre-existing compact WorkStation status typography; keeping both adjacent status rows aligned avoids a one-off size mismatch. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| -------- | --------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 115 | Loading spinner | keep with reason | Decorative spinner is `aria-hidden`, respects reduced motion, and does not replace the semantic progress element. | — | +| 122, 183 | `ProgressBar` | keep with reason | Shared component exposes `role="progressbar"`, a localized accessible name, determinate value when known, and human-readable value text. | — | +| 133, 195 | Progress text | keep with reason | `aria-live="polite"` announces throttled progress without moving focus or interrupting the user. | — | +| 208 | Retry button | keep with reason | Visible localized label supplies the accessible name and the design-system button supplies keyboard semantics. | — | + +## D5 — Visual Patterns Observed + +- Centered and compact loading states deliberately share the same `ProgressBar` component and progress model. +- The compact banner follows the existing WorkStation status-row pattern. No third independent custom implementation was introduced, so there is no abstraction candidate. + +## Summary + +- 0 fixes recommended +- 9 kept with documented reason +- 0 abstract candidates diff --git a/docs/frontend-ui-audit-2026-08-21/SimulatorStatusBarView.md b/docs/frontend-ui-audit-2026-08-21/SimulatorStatusBarView.md new file mode 100644 index 0000000000..0b89ef05ee --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-21/SimulatorStatusBarView.md @@ -0,0 +1,41 @@ +# Frontend UI Audit — SimulatorStatusBarView + +**File:** `src/engines/Simulator/components/SimulatorStatusBar/SimulatorStatusBarView.tsx` (183 LOC) +**Date:** 2026-08-21 +**Auditor:** Codex PR #850 CI repair + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| ---- | ----------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| 96 | free-browse ` 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..44db609fa1 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"; /** 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..882937795a 100644 --- a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx +++ b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx @@ -62,7 +62,6 @@ 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; 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..9f2870f219 --- /dev/null +++ b/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx @@ -0,0 +1,194 @@ +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/ChatPanelChrome.test.ts b/src/engines/ChatPanel/header/ChatPanelChrome.test.ts new file mode 100644 index 0000000000..3f2a9ee84f --- /dev/null +++ b/src/engines/ChatPanel/header/ChatPanelChrome.test.ts @@ -0,0 +1,39 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ChatPanelChrome } from "./ChatPanelChrome"; + +describe("ChatPanelChrome", () => { + it("shares the desktop tab row and published header frame", () => { + const markup = renderToStaticMarkup( + React.createElement(ChatPanelChrome, { + tabStrip: React.createElement("span", null, "Session tab"), + toolbar: React.createElement("button", null, "Refresh"), + publishedHeaderSlots: { + content: React.createElement("span", null, "SDE Agent"), + trailing: React.createElement("span", null, "Read only"), + }, + }) + ); + + expect(markup).toContain('data-testid="chat-panel-header-glass"'); + expect(markup).toContain('data-testid="chat-panel-header"'); + expect(markup).toContain('data-testid="chat-panel-published-header"'); + expect(markup).toContain("workspace-header header-tab-group"); + expect(markup).toContain("Session tab"); + expect(markup).toContain("SDE Agent"); + expect(markup).toContain("Read only"); + }); + + it("does not reserve a published-header row when no slots are supplied", () => { + const markup = renderToStaticMarkup( + React.createElement(ChatPanelChrome, { + tabStrip: React.createElement("span", null, "Only tab row"), + }) + ); + + expect(markup).not.toContain('data-testid="chat-panel-published-header"'); + expect(markup).toContain("height:44px"); + }); +}); diff --git a/src/engines/ChatPanel/header/ChatPanelChrome.tsx b/src/engines/ChatPanel/header/ChatPanelChrome.tsx new file mode 100644 index 0000000000..ec1c18af67 --- /dev/null +++ b/src/engines/ChatPanel/header/ChatPanelChrome.tsx @@ -0,0 +1,95 @@ +import React from "react"; + +import { getCollapsedSidebarChromeOffset } from "@src/hooks/ui/sidebar/useCollapsedSidebarChromeOffset"; +import { CollapsedSidebarButton } from "@src/scaffold/NavigationSidebar/CollapsedSidebarButton"; +import { isWindows } from "@src/util/platform/tauri"; + +import { + CHAT_PANEL_HEADER_DRAG_STYLE, + CHAT_PANEL_HEADER_NO_DRAG_STYLE, +} from "./ChatPanelHeaderPrimitives"; +import { ChatPanelPublishedHeader } from "./ChatPanelPublishedHeader"; +import { + CHAT_PANEL_GLASS_SURFACE_CLASS, + CHAT_PANEL_HEADER_STACK_HEIGHT_PX, + CHAT_PANEL_TAB_HEADER_HEIGHT_PX, +} from "./chatPanelHeaderLayout"; +import type { ChatPanelHeaderSlots } from "./chatPanelHeaderSlots"; + +export interface ChatPanelChromeProps { + tabStrip: React.ReactNode; + toolbar?: React.ReactNode; + publishedHeaderSlots?: ChatPanelHeaderSlots | null; + overlayPublishedHeader?: boolean; + shouldOffsetHeaderForCollapsedSidebar?: boolean; +} + +/** + * Platform-neutral presentation frame shared by the live desktop ChatPanel + * and read-only transcript hosts. State ownership stays with each host; this + * component owns only the canonical glass, tab row and published-header layout. + */ +export function ChatPanelChrome({ + tabStrip, + toolbar, + publishedHeaderSlots = null, + overlayPublishedHeader = false, + shouldOffsetHeaderForCollapsedSidebar = false, +}: ChatPanelChromeProps): React.ReactNode { + const windowsHost = isWindows(); + + return ( + <> +
+
+ {shouldOffsetHeaderForCollapsedSidebar ? ( +
+ +
+ ) : null} + {tabStrip} + {toolbar} +
+ {overlayPublishedHeader && publishedHeaderSlots ? ( +
+ +
+ ) : ( + + )} + + ); +} 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/useChatEventReplay.ts b/src/engines/ChatPanel/hooks/useChatEventReplay.ts index a4a9fdd574..acef363f59 100644 --- a/src/engines/ChatPanel/hooks/useChatEventReplay.ts +++ b/src/engines/ChatPanel/hooks/useChatEventReplay.ts @@ -13,6 +13,7 @@ import { useAtomCallback } from "jotai/utils"; import { useCallback } from "react"; import { REPLAY_CONFIG } from "@src/config/workspace/replayConfig"; +import { useSessionTranscriptRuntime } from "@src/engines/ChatPanel/SessionTranscriptRuntimeContext"; import { currentEventIdAtom, eventIndexAtom, @@ -28,6 +29,7 @@ import { isPlanDisplayEvent, planAliasesContain, } from "@src/engines/SessionCore/derived/planDisplayEvents"; +import { resolveReplayEventLookup } from "@src/engines/SessionCore/replay/resolveReplayEventLookup"; import { createLogger } from "@src/hooks/logger"; import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanelAtom"; import { @@ -60,15 +62,16 @@ export interface UseChatEventReplayReturn { * filtered from chat but still need to be navigable in the simulator. */ export function useChatEventReplay(): UseChatEventReplayReturn { + const runtime = useSessionTranscriptRuntime(); // Only subscribe to a boolean flag that flips once (empty → non-empty). // Reading the live event/replay atoms here would re-render every subscriber // (every chat block via `useBlockLocate`) on each streamed event. - const canReplay = useAtomValue(hasReplayableEventsAtom); + const canReplayFromStore = useAtomValue(hasReplayableEventsAtom); // Read the live atoms lazily at click time via the jotai store instead of // subscribing. This keeps `replayEventById` stable and decouples chat block // re-renders from the streaming event list / replay time range. - const replayEventById = useAtomCallback( + const replayEventViaStore = useAtomCallback( useCallback((get, set, eventId: string) => { if (!eventId) { log.warn("[ChatEventReplay] No event_id provided"); @@ -80,31 +83,30 @@ export function useChatEventReplay(): UseChatEventReplayReturn { const eventSecondaryLookup = get(eventSecondaryLookupAtom); const timeRange = get(replayTimeRangeAtom); - // Extract original ID if prefixed (e.g., "group:stageoutput:intake:uuid") - let lookupId = eventId; - if (eventId.startsWith("group:stageoutput:")) { - const parts = eventId.split(":"); - if (parts.length >= 4) { - lookupId = parts.slice(3).join(":"); - } - } - - let event = eventIndex.get(lookupId); + let event = resolveReplayEventLookup(sortedEvents, eventId); if (!event) { + const lookupId = eventId.startsWith("group:stageoutput:") + ? eventId.split(":").slice(3).join(":") + : eventId; const secondaryEventId = eventSecondaryLookup.chunkIdToEventId.get(lookupId) ?? eventSecondaryLookup.callIdToEventId.get(lookupId); - event = secondaryEventId ? eventIndex.get(secondaryEventId) : undefined; + event = secondaryEventId + ? (eventIndex.get(secondaryEventId) ?? null) + : null; } - event ??= sortedEvents.find((candidate) => { - if (!isPlanDisplayEvent(candidate)) return false; - return planAliasesContain(getPlanEventAliases(candidate), lookupId); - }); + event ??= + sortedEvents.find((candidate) => { + if (!isPlanDisplayEvent(candidate)) return false; + const lookupId = eventId.startsWith("group:stageoutput:") + ? eventId.split(":").slice(3).join(":") + : eventId; + return planAliasesContain(getPlanEventAliases(candidate), lookupId); + }) ?? null; if (!event) { log.warn( - `[ChatEventReplay] Event not found: ${lookupId}`, - eventId !== lookupId ? `(extracted from: ${eventId})` : "", + `[ChatEventReplay] Event not found: ${eventId}`, `| total events: ${sortedEvents.length}` ); return; @@ -130,7 +132,7 @@ export function useChatEventReplay(): UseChatEventReplayReturn { // `event.source === "user"`. if (process.env.NODE_ENV === "development" && event.functionName) { log.debug( - `[ChatEventReplay] Event ${lookupId}: ${event.functionName} → follow dock` + `[ChatEventReplay] Event ${resolvedEventId}: ${event.functionName} → follow dock` ); } @@ -172,8 +174,19 @@ export function useChatEventReplay(): UseChatEventReplayReturn { }, []) ); + const replayEventById = useCallback( + (eventId: string) => { + if (runtime?.onNavigateToEvent) { + runtime.onNavigateToEvent(eventId); + return; + } + replayEventViaStore(eventId); + }, + [replayEventViaStore, runtime] + ); + return { replayEventById, - canReplay, + canReplay: runtime?.onNavigateToEvent ? true : canReplayFromStore, }; } 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/bundledToolRegistryFallback.ts b/src/engines/SessionCore/rendering/registry/bundledToolRegistryFallback.ts new file mode 100644 index 0000000000..320bf9e557 --- /dev/null +++ b/src/engines/SessionCore/rendering/registry/bundledToolRegistryFallback.ts @@ -0,0 +1,112 @@ +/** + * Static tool-registry fallback for browser hosts where `init_tool_registry` + * Tauri IPC is unavailable. Mirrors the Rust source of truth used in tests. + */ +import { AppType } from "@src/engines/Simulator/types/appTypes"; + +import { + _setBuiltinAppSubtoolMap, + _setBuiltinSimulatorMap, + _setCliToolAliasMap, +} from "./initToolRegistry"; +import type { AliasEntry, AppSubtool } from "./types"; + +const codeRead = (storage: string, ui: string): AliasEntry => ({ + storage, + ui, + simulatorApp: "CODE_EDITOR", + appSubtool: "file_read", + chatBlock: "read_file", +}); + +const codeWrite = (storage: string, ui: string): AliasEntry => ({ + storage, + ui, + simulatorApp: "CODE_EDITOR", + appSubtool: "file_write", + chatBlock: "diff", +}); + +const BUILTIN_SUBTOOL: Map = new Map([ + ["read_file", "file_read"], + ["list_dir", "explore"], + ["run_shell", "shell"], + ["await_output", "shell"], + ["inspect_terminals", "shell"], + ["code_search", "explore"], + ["manage_workspace", "explore"], + ["edit_file", "file_write"], + ["delete_file", "file_write"], + ["edit_file_by_replace", "file_write"], + ["create_file", "file_write"], + ["write_file", "file_write"], + ["query_lsp", "explore"], + ["glob_file_search", "glob"], + ["web_search", "browser"], + ["web_fetch", "browser"], + ["agent_message", "message"], + ["thinking", "thinking"], +]); + +const BUILTIN_SIMULATOR: Map = new Map([ + ["read_file", AppType.CODE_EDITOR], + ["list_dir", AppType.CODE_EDITOR], + ["run_shell", AppType.CODE_EDITOR], + ["await_output", AppType.CODE_EDITOR], + ["inspect_terminals", AppType.CODE_EDITOR], + ["code_search", AppType.CODE_EDITOR], + ["manage_workspace", AppType.CODE_EDITOR], + ["edit_file", AppType.CODE_EDITOR], + ["delete_file", AppType.CODE_EDITOR], + ["edit_file_by_replace", AppType.CODE_EDITOR], + ["create_file", AppType.CODE_EDITOR], + ["write_file", AppType.CODE_EDITOR], + ["query_lsp", AppType.CODE_EDITOR], + ["glob_file_search", AppType.CODE_EDITOR], + ["web_search", AppType.BROWSER], + ["web_fetch", AppType.BROWSER], +]); + +const CLI_ALIASES: Map = new Map([ + ["Read", codeRead("read_file", "read_file")], + ["READ", codeRead("read_file", "read_file")], + ["read", codeRead("read_file", "read_file")], + ["read_file", codeRead("read_file", "read_file")], + ["ReadFile", codeRead("read_file", "read_file")], + ["readToolCall", codeRead("read_file", "read_file")], + ["file_read", codeRead("read_file", "read_file")], + ["cat", codeRead("read_file", "read_file")], + ["view_file", codeRead("read_file", "read_file")], + + ["Edit", codeWrite("edit_file_by_replace", "edit_file")], + ["EDIT", codeWrite("edit_file_by_replace", "edit_file")], + ["edit", codeWrite("edit_file_by_replace", "edit_file")], + ["edit_file", codeWrite("edit_file", "edit_file")], + ["MultiEdit", codeWrite("edit_file_by_replace", "edit_file")], + ["edit_file_by_replace", codeWrite("edit_file_by_replace", "edit_file")], + ["editToolCall", codeWrite("edit_file_by_replace", "edit_file")], + ["file_diff", codeWrite("edit_file_by_replace", "edit_file")], + ["append_file", codeWrite("edit_file_by_replace", "edit_file")], + ["file_range_edit", codeWrite("edit_file_by_replace", "edit_file")], + ["insert_content_at_line", codeWrite("edit_file_by_replace", "edit_file")], + + ["Write", codeWrite("create_file", "edit_file")], + ["WRITE", codeWrite("create_file", "edit_file")], + ["write", codeWrite("create_file", "edit_file")], + ["write_file", codeWrite("create_file", "edit_file")], + ["create_file", codeWrite("create_file", "edit_file")], + ["createToolCall", codeWrite("create_file", "edit_file")], + + ["Delete", codeWrite("delete_file", "delete_file")], + ["delete", codeWrite("delete_file", "delete_file")], + ["deleteToolCall", codeWrite("delete_file", "delete_file")], + ["remove_file", codeWrite("delete_file", "delete_file")], + ["delete_file", codeWrite("delete_file", "delete_file")], +]); + +/** Populate registry maps when Rust IPC is unavailable (ORG2 Web). */ +export function applyBundledToolRegistryFallback(): void { + _setBuiltinSimulatorMap(BUILTIN_SIMULATOR); + _setBuiltinAppSubtoolMap(BUILTIN_SUBTOOL); + _setCliToolAliasMap(CLI_ALIASES); +} diff --git a/src/engines/SessionCore/rendering/registry/initBundledToolRegistry.test.ts b/src/engines/SessionCore/rendering/registry/initBundledToolRegistry.test.ts new file mode 100644 index 0000000000..96ce8fedeb --- /dev/null +++ b/src/engines/SessionCore/rendering/registry/initBundledToolRegistry.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + _resetToolRegistry, + getAppSubtool, + getAppTypeForTool, + initBundledToolRegistry, + resolveCliAlias, +} from "./initToolRegistry"; + +describe("initBundledToolRegistry", () => { + afterEach(() => { + _resetToolRegistry(); + }); + + it("initializes the Web registry from bundled data without a desktop round trip", async () => { + _resetToolRegistry(); + + await initBundledToolRegistry(); + await initBundledToolRegistry(); + + expect(resolveCliAlias("Read")?.storage).toBe("read_file"); + expect(getAppTypeForTool("read_file")).toBe("CODE_EDITOR"); + expect(getAppSubtool("web_search")).toBe("browser"); + }); +}); diff --git a/src/engines/SessionCore/rendering/registry/initToolRegistry.ts b/src/engines/SessionCore/rendering/registry/initToolRegistry.ts index 6c40c758ec..0537626632 100644 --- a/src/engines/SessionCore/rendering/registry/initToolRegistry.ts +++ b/src/engines/SessionCore/rendering/registry/initToolRegistry.ts @@ -172,6 +172,34 @@ function publishToolClassifierRegistry(): void { // Initialization // ============================================ +async function applyBundledRegistry(): Promise { + 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(); +} + +/** + * Initialize the browser build from its bundled registry without attempting + * desktop-only Tauri IPC. Safe to call multiple times. + */ +export async function initBundledToolRegistry(): Promise { + if (initAttempted) return; + initAttempted = true; + await applyBundledRegistry(); +} + /** * Initialize the tool registry from Rust via single IPC call. * Safe to call multiple times; only fetches once. @@ -258,18 +286,7 @@ 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(); - publishToolClassifierRegistry(); + await applyBundledRegistry(); } } diff --git a/src/engines/SessionCore/replay/__tests__/projectReplayEventWindow.test.ts b/src/engines/SessionCore/replay/__tests__/projectReplayEventWindow.test.ts new file mode 100644 index 0000000000..d7361c78f7 --- /dev/null +++ b/src/engines/SessionCore/replay/__tests__/projectReplayEventWindow.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + projectReplayVisibleEvents, + resolveReplayEventWindow, +} from "../projectReplayEventWindow"; + +describe("resolveReplayEventWindow", () => { + it("returns the full window in follow mode", () => { + expect(resolveReplayEventWindow(5, "follow", 2)).toEqual({ + endIndex: 4, + isFullWindow: true, + }); + }); + + it("clamps scrub indices to the event range", () => { + expect(resolveReplayEventWindow(5, "paused", 99)).toEqual({ + endIndex: 4, + isFullWindow: false, + }); + expect(resolveReplayEventWindow(5, "playing", -3)).toEqual({ + endIndex: 0, + isFullWindow: false, + }); + }); +}); + +describe("projectReplayVisibleEvents", () => { + const events = ["a", "b", "c", "d"]; + + it("reuses the source array in follow mode", () => { + const window = resolveReplayEventWindow(events.length, "follow", 1); + expect(projectReplayVisibleEvents(events, window)).toBe(events); + }); + + it("projects a prefix during replay scrub", () => { + const window = resolveReplayEventWindow(events.length, "paused", 1); + expect(projectReplayVisibleEvents(events, window)).toEqual(["a", "b"]); + }); +}); diff --git a/src/engines/SessionCore/replay/__tests__/resolveReplayEventLookup.test.ts b/src/engines/SessionCore/replay/__tests__/resolveReplayEventLookup.test.ts new file mode 100644 index 0000000000..5c157b48ca --- /dev/null +++ b/src/engines/SessionCore/replay/__tests__/resolveReplayEventLookup.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + resolveReplayEventIndex, + resolveReplayEventLookup, +} from "../resolveReplayEventLookup"; + +function event( + id: string, + overrides: Partial = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "session-1", + createdAt: "2026-08-19T00:00:00.000Z", + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "processed", + ...overrides, + } as SessionEvent; +} + +describe("resolveReplayEventLookup", () => { + it("resolves by canonical event id", () => { + const events = [event("read-a"), event("read-b")]; + expect(resolveReplayEventLookup(events, "read-b")?.id).toBe("read-b"); + }); + + it("resolves chunk_id aliases", () => { + const events = [ + event("event-id", { chunk_id: "legacy-chunk-id" }), + event("other"), + ]; + expect(resolveReplayEventLookup(events, "legacy-chunk-id")?.id).toBe( + "event-id" + ); + }); + + it("returns null when the id is unknown", () => { + expect(resolveReplayEventLookup([event("read-a")], "missing")).toBeNull(); + }); +}); + +describe("resolveReplayEventIndex", () => { + it("returns the index of the resolved event", () => { + const events = [event("a"), event("b"), event("c")]; + expect(resolveReplayEventIndex(events, "b")).toBe(1); + }); + + it("returns -1 when lookup fails", () => { + expect(resolveReplayEventIndex([event("a")], "missing")).toBe(-1); + }); +}); diff --git a/src/engines/SessionCore/replay/projectReplayEventWindow.ts b/src/engines/SessionCore/replay/projectReplayEventWindow.ts new file mode 100644 index 0000000000..44b949fad7 --- /dev/null +++ b/src/engines/SessionCore/replay/projectReplayEventWindow.ts @@ -0,0 +1,38 @@ +import type { ReplayPhase } from "./replayController"; + +export interface ReplayEventWindow { + endIndex: number; + /** When true, consumers may use the full `events` array without slicing. */ + isFullWindow: boolean; +} + +export function resolveReplayEventWindow( + eventCount: number, + phase: ReplayPhase, + index: number +): ReplayEventWindow { + if (eventCount <= 0) { + return { endIndex: -1, isFullWindow: true }; + } + if (phase === "follow") { + return { endIndex: eventCount - 1, isFullWindow: true }; + } + const lastIndex = eventCount - 1; + return { + endIndex: Math.min(Math.max(index, 0), lastIndex), + isFullWindow: false, + }; +} + +export function projectReplayVisibleEvents( + events: readonly T[], + window: ReplayEventWindow +): readonly T[] { + if (events.length === 0 || window.isFullWindow) { + return events; + } + if (window.endIndex < 0) { + return events.slice(0, 0); + } + return events.slice(0, window.endIndex + 1); +} diff --git a/src/engines/SessionCore/replay/remoteReplaySnapshot.test.ts b/src/engines/SessionCore/replay/remoteReplaySnapshot.test.ts new file mode 100644 index 0000000000..f4ccadbfe3 --- /dev/null +++ b/src/engines/SessionCore/replay/remoteReplaySnapshot.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { buildRemoteReplaySnapshot } from "./remoteReplaySnapshot"; + +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", + ...overrides, + } as SessionEvent; +} + +describe("buildRemoteReplaySnapshot", () => { + it("projects cloud events into the desktop replay snapshot contract", () => { + const later = event("later", { + createdAt: "2026-08-19T00:00:02.000Z", + }); + const earlier = event("earlier", { + createdAt: "2026-08-19T00:00:01.000Z", + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + }); + + const snapshot = buildRemoteReplaySnapshot([later, earlier], { + version: 7, + }); + + expect(snapshot.version).toBe(7); + expect(snapshot.events).toEqual([later, earlier]); + expect(snapshot.eventIndex).toEqual({ later: 0, earlier: 1 }); + expect(snapshot.sortedSimulatorEventIds).toEqual(["earlier", "later"]); + expect(snapshot.eventPreviewById?.earlier.functionName).toBe("read_file"); + }); + + it("does not expose delta and tool-result rows as workstation frames", () => { + const delta = event("delta", { isDelta: true }); + const result = event("result", { + actionType: "tool_result", + displayVariant: "tool_call", + }); + + const snapshot = buildRemoteReplaySnapshot([delta, result], { + version: 8, + }); + + expect(snapshot.sortedSimulatorEvents).toEqual([]); + expect(snapshot.sortedSimulatorEventIds).toEqual([]); + }); + + it("projects only the replay prefix when endIndex is provided", () => { + const first = event("first", { isDelta: true }); + const second = event("second", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + }); + const third = event("third"); + + const snapshot = buildRemoteReplaySnapshot([first, second, third], { + endIndex: 1, + version: 9, + }); + + expect(snapshot.events).toEqual([first, second]); + expect(snapshot.sortedSimulatorEventIds).toEqual(["second"]); + expect(snapshot.version).toBe(9); + }); +}); diff --git a/src/engines/SessionCore/replay/remoteReplaySnapshot.ts b/src/engines/SessionCore/replay/remoteReplaySnapshot.ts new file mode 100644 index 0000000000..b9134db06e --- /dev/null +++ b/src/engines/SessionCore/replay/remoteReplaySnapshot.ts @@ -0,0 +1,66 @@ +import { + buildSimulatorPreviewFields, + isSimulatorVisibleApprox, +} from "@src/engines/SessionCore/core/atoms/actions.simulatorPreview"; +import { isLiveRuntimeResourceEvent } from "@src/engines/SessionCore/core/runningEventGate"; +import type { DerivedSnapshot } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion"; + +function eventTime(event: SessionEvent): number { + const parsed = Date.parse(event.createdAt); + return Number.isFinite(parsed) ? parsed : 0; +} + +function compareReplayEvents(left: SessionEvent, right: SessionEvent): number { + const timeDifference = eventTime(left) - eventTime(right); + return timeDifference || left.id.localeCompare(right.id); +} + +/** + * Build the same snapshot shape consumed by the desktop WorkStation replay + * from an already-authorized remote event list. + * + * The Cloud transport remains the source of truth. This projection performs + * no persistence and never writes into the desktop EventStore; callers mount + * it in an isolated Jotai store. + */ +export interface BuildRemoteReplaySnapshotOptions { + /** Inclusive replay cursor; defaults to the full event list. */ + endIndex?: number; + version?: number; +} + +export function buildRemoteReplaySnapshot( + events: readonly SessionEvent[], + options: BuildRemoteReplaySnapshotOptions = {} +): DerivedSnapshot { + const version = options.version ?? Date.now(); + const lastIndex = events.length - 1; + const endIndex = + options.endIndex === undefined + ? lastIndex + : Math.min(Math.max(options.endIndex, -1), lastIndex); + const materializedEvents = + endIndex >= lastIndex ? [...events] : events.slice(0, endIndex + 1); + const chatEvents = materializedEvents.filter(isVisibleInChat); + const simulatorEvents = materializedEvents + .filter(isSimulatorVisibleApprox) + .sort(compareReplayEvents); + + return { + version, + eventCount: materializedEvents.length, + events: materializedEvents, + chatEvents, + messagesEvents: simulatorEvents, + sortedSimulatorEvents: simulatorEvents, + lastEvent: materializedEvents[materializedEvents.length - 1] ?? null, + eventIndex: Object.fromEntries( + materializedEvents.map((event, index) => [event.id, index]) + ), + chatEventCount: chatEvents.length, + hasRunningEvent: materializedEvents.some(isLiveRuntimeResourceEvent), + ...buildSimulatorPreviewFields(simulatorEvents), + }; +} diff --git a/src/engines/SessionCore/replay/replayController.test.ts b/src/engines/SessionCore/replay/replayController.test.ts new file mode 100644 index 0000000000..7dc5079f84 --- /dev/null +++ b/src/engines/SessionCore/replay/replayController.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + createReplayControllerState, + replayControllerReducer, +} from "./replayController"; + +describe("replayControllerReducer", () => { + it("follows newly appended events while live", () => { + const state = createReplayControllerState(2); + expect( + replayControllerReducer(state, { type: "sync", eventCount: 4 }) + ).toMatchObject({ + phase: "follow", + index: 3, + eventCount: 4, + }); + }); + + it("preserves a paused replay cursor when events append", () => { + const paused = replayControllerReducer(createReplayControllerState(4), { + type: "seek", + index: 1, + }); + expect( + replayControllerReducer(paused, { type: "sync", eventCount: 6 }) + ).toMatchObject({ phase: "paused", index: 1, eventCount: 6 }); + }); + + it("enters free browsing at the latest event without moving the cursor", () => { + const state = replayControllerReducer(createReplayControllerState(4), { + type: "browse", + }); + + expect(state).toMatchObject({ phase: "paused", index: 3, eventCount: 4 }); + }); + + it("restarts from the first event and ends monotonically", () => { + let state = replayControllerReducer(createReplayControllerState(3), { + type: "play", + }); + expect(state).toMatchObject({ phase: "playing", index: 0 }); + state = replayControllerReducer(state, { type: "tick" }); + expect(state).toMatchObject({ phase: "playing", index: 1 }); + state = replayControllerReducer(state, { type: "tick" }); + expect(state).toMatchObject({ phase: "ended", index: 2 }); + expect(replayControllerReducer(state, { type: "tick" })).toEqual(state); + }); +}); diff --git a/src/engines/SessionCore/replay/replayController.ts b/src/engines/SessionCore/replay/replayController.ts new file mode 100644 index 0000000000..78d3c41d1a --- /dev/null +++ b/src/engines/SessionCore/replay/replayController.ts @@ -0,0 +1,118 @@ +import { + REPLAY_SPEED_OPTIONS, + type ReplaySpeed, +} from "@src/config/workspace/replayConfig"; + +export const REPLAY_SPEEDS = REPLAY_SPEED_OPTIONS; +export type { ReplaySpeed }; +export type ReplayPhase = "follow" | "paused" | "playing" | "ended"; + +export interface ReplayControllerState { + phase: ReplayPhase; + eventCount: number; + index: number; + speed: ReplaySpeed; +} + +export type ReplayControllerAction = + | { type: "sync"; eventCount: number } + | { type: "seek"; index: number } + | { type: "play" } + | { type: "pause" } + | { type: "browse" } + | { type: "tick" } + | { type: "follow" } + | { type: "set-speed"; speed: ReplaySpeed }; + +function lastIndex(eventCount: number): number { + return Math.max(-1, eventCount - 1); +} + +function clampIndex(index: number, eventCount: number): number { + return Math.min( + Math.max(index, eventCount > 0 ? 0 : -1), + lastIndex(eventCount) + ); +} + +export function createReplayControllerState( + eventCount: number +): ReplayControllerState { + return { + phase: "follow", + eventCount, + index: lastIndex(eventCount), + speed: 1, + }; +} + +export function replayControllerReducer( + state: ReplayControllerState, + action: ReplayControllerAction +): ReplayControllerState { + switch (action.type) { + case "sync": { + const eventCount = Math.max(0, action.eventCount); + if (state.phase === "follow") { + return { ...state, eventCount, index: lastIndex(eventCount) }; + } + const index = clampIndex(state.index, eventCount); + return { + ...state, + eventCount, + index, + phase: + state.phase === "playing" && index >= lastIndex(eventCount) + ? "ended" + : state.phase, + }; + } + case "seek": { + const index = clampIndex(action.index, state.eventCount); + return { + ...state, + index, + phase: index === lastIndex(state.eventCount) ? "follow" : "paused", + }; + } + case "play": { + if (state.eventCount === 0) return state; + const atEnd = state.index >= lastIndex(state.eventCount); + return { + ...state, + index: atEnd ? 0 : Math.max(0, state.index), + phase: state.eventCount === 1 ? "ended" : "playing", + }; + } + case "pause": + return state.phase === "playing" ? { ...state, phase: "paused" } : state; + case "browse": + return state.eventCount === 0 + ? state + : { + ...state, + phase: "paused", + index: clampIndex(state.index, state.eventCount), + }; + case "tick": { + if (state.phase !== "playing") return state; + const nextIndex = state.index + 1; + if (nextIndex >= lastIndex(state.eventCount)) { + return { + ...state, + index: lastIndex(state.eventCount), + phase: "ended", + }; + } + return { ...state, index: nextIndex }; + } + case "follow": + return { + ...state, + phase: "follow", + index: lastIndex(state.eventCount), + }; + case "set-speed": + return { ...state, speed: action.speed }; + } +} diff --git a/src/engines/SessionCore/replay/resolveReplayEventLookup.ts b/src/engines/SessionCore/replay/resolveReplayEventLookup.ts new file mode 100644 index 0000000000..b2a416709b --- /dev/null +++ b/src/engines/SessionCore/replay/resolveReplayEventLookup.ts @@ -0,0 +1,61 @@ +import { + getPlanEventAliases, + isPlanDisplayEvent, + planAliasesContain, +} from "@src/engines/SessionCore/derived/planDisplayEvents"; + +import type { SessionEvent } from "../core/types"; + +/** + * Resolve a chat/simulator navigation id to the canonical session event. + * Mirrors the lookup rules in `useChatEventReplay` for remote surfaces that + * do not mount the desktop EventStore. + */ +export function resolveReplayEventLookup( + events: SessionEvent[], + eventId: string +): SessionEvent | null { + if (!eventId || events.length === 0) return null; + + let lookupId = eventId; + if (eventId.startsWith("group:stageoutput:")) { + const parts = eventId.split(":"); + if (parts.length >= 4) { + lookupId = parts.slice(3).join(":"); + } + } + + const byId = new Map(); + const chunkIdToEventId = new Map(); + for (const event of events) { + byId.set(event.id, event); + const chunkId = event.chunk_id; + if (typeof chunkId === "string" && chunkId.length > 0) { + chunkIdToEventId.set(chunkId, event.id); + } + } + + let resolved = byId.get(lookupId) ?? null; + if (!resolved) { + const mappedId = chunkIdToEventId.get(lookupId); + resolved = mappedId ? (byId.get(mappedId) ?? null) : null; + } + if (!resolved) { + resolved = + events.find((candidate) => { + if (!isPlanDisplayEvent(candidate)) return false; + return planAliasesContain(getPlanEventAliases(candidate), lookupId); + }) ?? null; + } + + return resolved; +} + +export function resolveReplayEventIndex( + events: SessionEvent[], + eventId: string +): number { + const event = resolveReplayEventLookup(events, eventId); + if (!event) return -1; + return events.findIndex((candidate) => candidate.id === event.id); +} diff --git a/src/engines/SessionCore/replay/useReplayController.test.ts b/src/engines/SessionCore/replay/useReplayController.test.ts new file mode 100644 index 0000000000..dcea2cc2dd --- /dev/null +++ b/src/engines/SessionCore/replay/useReplayController.test.ts @@ -0,0 +1,67 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; + +import { useReplayController } from "./useReplayController"; + +function ReplayProbe() { + const replay = useReplayController(3); + return React.createElement( + "div", + null, + React.createElement( + "button", + { type: "button", "data-play": true, onClick: replay.play }, + "Play" + ), + React.createElement("output", { + "data-phase": replay.state.phase, + "data-index": replay.state.index, + }) + ); +} + +describe("useReplayController", () => { + let visibilityState: DocumentVisibilityState; + + beforeEach(() => { + vi.useFakeTimers(); + visibilityState = "visible"; + vi.spyOn(document, "visibilityState", "get").mockImplementation( + () => visibilityState + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("pauses playback timers while hidden and disposes them on unmount", async () => { + const root = createSmokeRoot(); + await root.render(React.createElement(ReplayProbe)); + + await dispatch(() => + root.container.querySelector("[data-play]")?.click() + ); + expect(root.container.querySelector("output")?.dataset).toMatchObject({ + phase: "playing", + index: "0", + }); + + visibilityState = "hidden"; + await dispatch(() => document.dispatchEvent(new Event("visibilitychange"))); + await dispatch(() => vi.advanceTimersByTime(5_000)); + expect(root.container.querySelector("output")?.dataset.index).toBe("0"); + + visibilityState = "visible"; + await dispatch(() => document.dispatchEvent(new Event("visibilitychange"))); + await dispatch(() => vi.advanceTimersByTime(700)); + expect(root.container.querySelector("output")?.dataset.index).toBe("1"); + + await root.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/src/engines/SessionCore/replay/useReplayController.ts b/src/engines/SessionCore/replay/useReplayController.ts new file mode 100644 index 0000000000..f3aec295c4 --- /dev/null +++ b/src/engines/SessionCore/replay/useReplayController.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useReducer, useState } from "react"; + +import { + type ReplaySpeed, + createReplayControllerState, + replayControllerReducer, +} from "./replayController"; + +const BASE_STEP_MS = 700; + +function documentIsVisible(): boolean { + return ( + typeof document === "undefined" || document.visibilityState !== "hidden" + ); +} + +export function useReplayController(eventCount: number) { + const [state, dispatch] = useReducer( + replayControllerReducer, + eventCount, + createReplayControllerState + ); + const [documentVisible, setDocumentVisible] = useState(documentIsVisible); + + useEffect(() => { + dispatch({ type: "sync", eventCount }); + }, [eventCount]); + + useEffect(() => { + const handleVisibilityChange = () => + setDocumentVisible(documentIsVisible()); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => + document.removeEventListener("visibilitychange", handleVisibilityChange); + }, []); + + useEffect(() => { + if (state.phase !== "playing" || !documentVisible) return; + const timer = window.setTimeout( + () => dispatch({ type: "tick" }), + BASE_STEP_MS / state.speed + ); + return () => window.clearTimeout(timer); + }, [documentVisible, state.index, state.phase, state.speed]); + + const seek = useCallback( + (index: number) => dispatch({ type: "seek", index }), + [] + ); + const play = useCallback(() => dispatch({ type: "play" }), []); + const pause = useCallback(() => dispatch({ type: "pause" }), []); + const browse = useCallback(() => dispatch({ type: "browse" }), []); + const follow = useCallback(() => dispatch({ type: "follow" }), []); + const setSpeed = useCallback( + (speed: ReplaySpeed) => dispatch({ type: "set-speed", speed }), + [] + ); + + return { state, seek, play, pause, browse, follow, setSpeed }; +} diff --git a/src/engines/Simulator/ActivitySimulator.tsx b/src/engines/Simulator/ActivitySimulator.tsx index 4a4dc8174d..9aea45d499 100644 --- a/src/engines/Simulator/ActivitySimulator.tsx +++ b/src/engines/Simulator/ActivitySimulator.tsx @@ -48,290 +48,323 @@ import MiniCPMStepExplanationPanel from "./components/MiniCPMStepExplanationPane import MusicPlayerReplayBar from "./components/MusicPlayerReplayBar"; import SimulatorFloatingInput from "./components/SimulatorFloatingInput"; import { SubagentPipCard } from "./components/SubagentPipCard"; +import { ReplayControlHostContext } from "./context/ReplayControlHostContext"; import { useSimulatorDisplayState } from "./hooks/useSimulatorDisplayState"; import { useSimulatorSession } from "./hooks/useSimulatorSession"; import { useSimulatorSubagents } from "./hooks/useSimulatorSubagents"; import { AppType } from "./types/appTypes"; -const ActivitySimulator: React.FC = memo(() => { - const { t } = useTranslation("sessions"); - // ── Atoms (same set as original ActivitySimulator) ───────────────────── - const manualLayout = useAtomValue(simulatorLayoutAtom); - const autoLayoutEnabled = useAtomValue(simulatorAutoLayoutAtom); - const showDock = useAtomValue(simulatorShowDockAtom); - const workStationLayoutMode = useAtomValue(workStationLayoutModeAtom); - const chatVisible = useAtomValue(chatVisibleAtom); - const simulatorInputCollapsed = useAtomValue( - simulatorInlineChatInputCollapsedAtom - ); - const [showMiniCPMStepExplanation, setShowMiniCPMStepExplanation] = useAtom( - simulatorMiniCPMStepExplanationVisibleAtom - ); - const [selectedApp, setSelectedApp] = useAtom(simulatorSelectedAppAtom); - const replayMode = useAtomValue(replayModeAtom); - const setReplayMode = useSetAtom(replayModeAtom) as ( - mode: ReplayMode - ) => void; - const setEffectiveDockApp = useSetAtom(simulatorEffectiveDockAppAtom); - const followAppLock = useAtomValue(simulatorFollowAppLockAtom); - const setDiffScope = useSetAtom(simulatorDiffScopeRequestAtom); - const refreshDiff = useSetAtom(bumpSimulatorDiffRefreshNonceAtom); +export interface ActivitySimulatorProps { + /** Replay cursor is owned by a parent surface (for example Cloud Web). */ + externalReplayControl?: boolean; + /** Native child-session queries are unavailable on remote/browser hosts. */ + subagentsEnabled?: boolean; + /** Desktop-only local composer overlay. */ + floatingInputEnabled?: boolean; +} - const floatingDockComposerAlignClass = - workStationLayoutMode === "left" ? "items-end" : "items-start"; - - // ── Core session state ───────────────────────────────────────────────── - const { - sessionId, - hasSession, - eventIds, - eventById, - previewById, - specs, - filteredEvents, - allEvents, - currentEvent, - currentEventIndex, - eventStoreVersion, - mainCursorMs, - selectedTaskId, - executionThreads, - executionThreadCount, - } = useSimulatorSession(); +const ActivitySimulator: React.FC = memo( + ({ + externalReplayControl = false, + subagentsEnabled = true, + floatingInputEnabled = true, + }) => { + const { t } = useTranslation("sessions"); + // ── Atoms (same set as original ActivitySimulator) ───────────────────── + const manualLayout = useAtomValue(simulatorLayoutAtom); + const autoLayoutEnabled = useAtomValue(simulatorAutoLayoutAtom); + const showDock = useAtomValue(simulatorShowDockAtom); + const workStationLayoutMode = useAtomValue(workStationLayoutModeAtom); + const chatVisible = useAtomValue(chatVisibleAtom); + const simulatorInputCollapsed = useAtomValue( + simulatorInlineChatInputCollapsedAtom + ); + const [showMiniCPMStepExplanation, setShowMiniCPMStepExplanation] = useAtom( + simulatorMiniCPMStepExplanationVisibleAtom + ); + const [selectedApp, setSelectedApp] = useAtom(simulatorSelectedAppAtom); + const replayMode = useAtomValue(replayModeAtom); + const setReplayMode = useSetAtom(replayModeAtom) as ( + mode: ReplayMode + ) => void; + const setEffectiveDockApp = useSetAtom(simulatorEffectiveDockAppAtom); + const followAppLock = useAtomValue(simulatorFollowAppLockAtom); + const setDiffScope = useSetAtom(simulatorDiffScopeRequestAtom); + const refreshDiff = useSetAtom(bumpSimulatorDiffRefreshNonceAtom); - // ── Pure derived display state ───────────────────────────────────────── - const { - effectiveSelectedApp, - displayEvent, - dockActiveApp, - currentWorkingApp, - layout, - } = useSimulatorDisplayState({ - selectedApp, - followAppLock, - eventIds, - eventById, - previewById, - filteredEvents, - currentEvent, - currentEventIndex, - selectedTaskId, - executionThreadCount, - executionThreads, - replayMode, - autoLayoutEnabled, - manualLayout, - }); + const floatingDockComposerAlignClass = + workStationLayoutMode === "left" ? "items-end" : "items-start"; - // Sync effective dock app to atom for app mode controls. - useEffect(() => { - setEffectiveDockApp(dockActiveApp); - }, [dockActiveApp, setEffectiveDockApp]); + // ── Core session state ───────────────────────────────────────────────── + const { + sessionId, + hasSession, + eventIds, + eventById, + previewById, + specs, + filteredEvents, + allEvents, + currentEvent, + currentEventIndex, + eventStoreVersion, + mainCursorMs, + selectedTaskId, + executionThreads, + executionThreadCount, + } = useSimulatorSession(); - // ── Subagent split pane ──────────────────────────────────────────────── - const { activeSubagents, hasActiveSubagents } = useSimulatorSubagents({ - sessionId, - eventStoreVersion, - currentEvent, - allEvents, - }); + // ── Pure derived display state ───────────────────────────────────────── + const { + effectiveSelectedApp, + displayEvent, + dockActiveApp, + currentWorkingApp, + layout, + } = useSimulatorDisplayState({ + selectedApp, + followAppLock, + eventIds, + eventById, + previewById, + filteredEvents, + currentEvent, + currentEventIndex, + selectedTaskId, + executionThreadCount, + executionThreads, + replayMode, + autoLayoutEnabled, + manualLayout, + }); - // When subagents are active the layout automatically becomes a split view - // (main agent top, subagent banner bottom). No dock switch or pip toggle - // is needed — presence of subagents is the only condition. - const prevHasSubagentsRef = useRef(hasActiveSubagents); - useEffect(() => { - const wasActive = prevHasSubagentsRef.current; - prevHasSubagentsRef.current = hasActiveSubagents; + // Sync effective dock app to atom for app mode controls. + useEffect(() => { + setEffectiveDockApp(dockActiveApp); + }, [dockActiveApp, setEffectiveDockApp]); - // When subagents disappear and the dock is stuck on BACKGROUND_TASKS, - // reset it so the main grid takes over. - if ( - wasActive && - !hasActiveSubagents && - selectedApp === AppType.BACKGROUND_TASKS - ) { - setSelectedApp(null); - } - }, [hasActiveSubagents, selectedApp, setSelectedApp]); + // ── Subagent split pane ──────────────────────────────────────────────── + const { activeSubagents, hasActiveSubagents } = useSimulatorSubagents({ + sessionId, + eventStoreVersion, + currentEvent, + allEvents, + enabled: subagentsEnabled, + }); - // ── Dock context menu ────────────────────────────────────────────────── - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - position: { x: number; y: number }; - targetApp: DockApp | null; - }>({ - visible: false, - position: { x: 0, y: 0 }, - targetApp: null, - }); + // When subagents are active the layout automatically becomes a split view + // (main agent top, subagent banner bottom). No dock switch or pip toggle + // is needed — presence of subagents is the only condition. + const prevHasSubagentsRef = useRef(hasActiveSubagents); + useEffect(() => { + const wasActive = prevHasSubagentsRef.current; + prevHasSubagentsRef.current = hasActiveSubagents; - const handleDockAppClick = useCallback( - (appId: string, _event?: React.MouseEvent) => { - // Clicking the dock = user wants to drive selectedApp manually, - // which conflicts with follow mode's "agent decides the selected - // app" semantics. Drop to replay so the user's pick sticks. Event - // tab clicks already exit follow via navigateToEventAndUpdateBar. - if (replayMode === "follow") { - setReplayMode("replay"); + // When subagents disappear and the dock is stuck on BACKGROUND_TASKS, + // reset it so the main grid takes over. + if ( + wasActive && + !hasActiveSubagents && + selectedApp === AppType.BACKGROUND_TASKS + ) { + setSelectedApp(null); } - // Manually opening the Diff app from the dock is the "whole-session - // diff" entry point — clear any per-round scope left over from a chat - // `TurnMetadataFooter` "Review" click (which only the composer files-pill - // otherwise clears) and refresh so the full view reflects the latest - // working tree without re-applying a stale file-focus request. - if ((appId as AppType) === AppType.DIFF) { - setDiffScope(null); - refreshDiff(); - } - setSelectedApp(appId as AppType); - }, - [replayMode, setReplayMode, setSelectedApp, setDiffScope, refreshDiff] - ); + }, [hasActiveSubagents, selectedApp, setSelectedApp]); - const handleDockAppContextMenu = useCallback( - (appId: string, event: React.MouseEvent) => { - const app = getAppById(appId); - if (app) { - setContextMenu({ - visible: true, - position: { x: event.clientX, y: event.clientY }, - targetApp: app, - }); - } - }, - [] - ); + // ── Dock context menu ────────────────────────────────────────────────── + const [contextMenu, setContextMenu] = useState<{ + visible: boolean; + position: { x: number; y: number }; + targetApp: DockApp | null; + }>({ + visible: false, + position: { x: 0, y: 0 }, + targetApp: null, + }); - const handleSwitchTo = useCallback( - (appId: string) => { - handleDockAppClick(appId); + const handleDockAppClick = useCallback( + (appId: string, _event?: React.MouseEvent) => { + // Clicking the dock = user wants to drive selectedApp manually, + // which conflicts with follow mode's "agent decides the selected + // app" semantics. Drop to replay so the user's pick sticks. Event + // tab clicks already exit follow via navigateToEventAndUpdateBar. + if (!externalReplayControl && replayMode === "follow") { + setReplayMode("replay"); + } + // Manually opening the Diff app from the dock is the "whole-session + // diff" entry point — clear any per-round scope left over from a chat + // `TurnMetadataFooter` "Review" click (which only the composer files-pill + // otherwise clears) and refresh so the full view reflects the latest + // working tree without re-applying a stale file-focus request. + if ((appId as AppType) === AppType.DIFF) { + setDiffScope(null); + refreshDiff(); + } + setSelectedApp(appId as AppType); + }, + [ + externalReplayControl, + replayMode, + setReplayMode, + setSelectedApp, + setDiffScope, + refreshDiff, + ] + ); + + const handleDockAppContextMenu = useCallback( + (appId: string, event: React.MouseEvent) => { + const app = getAppById(appId); + if (app) { + setContextMenu({ + visible: true, + position: { x: event.clientX, y: event.clientY }, + targetApp: app, + }); + } + }, + [] + ); + + const handleSwitchTo = useCallback( + (appId: string) => { + handleDockAppClick(appId); + setContextMenu((prev) => ({ ...prev, visible: false })); + }, + [handleDockAppClick] + ); + + const closeContextMenu = useCallback(() => { setContextMenu((prev) => ({ ...prev, visible: false })); - }, - [handleDockAppClick] - ); + }, []); - const closeContextMenu = useCallback(() => { - setContextMenu((prev) => ({ ...prev, visible: false })); - }, []); + // ── Render ───────────────────────────────────────────────────────────── + const gridProps = { + layout, + currentEvent: displayEvent, + events: filteredEvents, + specs, + forceAppType: effectiveSelectedApp, + taskThreads: selectedTaskId ? [] : executionThreads, + selectedThreadId: selectedTaskId, + }; - // ── Render ───────────────────────────────────────────────────────────── - const gridProps = { - layout, - currentEvent: displayEvent, - events: filteredEvents, - specs, - forceAppType: effectiveSelectedApp, - taskThreads: selectedTaskId ? [] : executionThreads, - selectedThreadId: selectedTaskId, - }; + // In the split-view the main grid must not be forced to BACKGROUND_TASKS. + // BACKGROUND_TASKS has no visual content of its own — forcing it makes the + // grid blank. When it is selected we also have to replace displayEvent with + // the raw currentEvent because useSimulatorDisplayState returns null for + // displayEvent when selectedApp=BACKGROUND_TASKS (no matching events exist). + // taskThreads is also neutralized: the PIP bottom strip already renders one + // cell per subagent, so letting the TOP pane enter multi-task grid mode + // duplicates the same monitoring row twice (stacked "two rows" look). + const isBgTasksSelected = effectiveSelectedApp === AppType.BACKGROUND_TASKS; + const splitGridProps = { + ...gridProps, + forceAppType: isBgTasksSelected ? null : effectiveSelectedApp, + currentEvent: isBgTasksSelected ? currentEvent : displayEvent, + taskThreads: [], + }; + const showFloatingInputOverlay = + floatingInputEnabled && + showDock && + !chatVisible && + hasSession && + !simulatorInputCollapsed; + const showReplayBar = + showDock && + Boolean(sessionId) && + !externalReplayControl && + replayMode !== "follow" && + dockActiveApp !== AppType.DIFF; - // In the split-view the main grid must not be forced to BACKGROUND_TASKS. - // BACKGROUND_TASKS has no visual content of its own — forcing it makes the - // grid blank. When it is selected we also have to replace displayEvent with - // the raw currentEvent because useSimulatorDisplayState returns null for - // displayEvent when selectedApp=BACKGROUND_TASKS (no matching events exist). - // taskThreads is also neutralized: the PIP bottom strip already renders one - // cell per subagent, so letting the TOP pane enter multi-task grid mode - // duplicates the same monitoring row twice (stacked "two rows" look). - const isBgTasksSelected = effectiveSelectedApp === AppType.BACKGROUND_TASKS; - const splitGridProps = { - ...gridProps, - forceAppType: isBgTasksSelected ? null : effectiveSelectedApp, - currentEvent: isBgTasksSelected ? currentEvent : displayEvent, - taskThreads: [], - }; - const showFloatingInputOverlay = - showDock && !chatVisible && hasSession && !simulatorInputCollapsed; - const showReplayBar = - showDock && - Boolean(sessionId) && - replayMode !== "follow" && - dockActiveApp !== AppType.DIFF; + if (!hasSession) { + return ( +
+ + {t("simulator.noActiveSession")} + +
+ ); + } - if (!hasSession) { return ( -
- - {t("simulator.noActiveSession")} - -
- ); - } + + +
+
+
+
+
+ {hasActiveSubagents ? ( + /* Split view: main agent (top) + subagent banner (bottom) */ + + } + activeSessions={activeSubagents} + mainCursorMs={mainCursorMs} + liveFollow={replayMode === "follow"} + /> + ) : ( + + )} - return ( - -
-
-
-
-
- {hasActiveSubagents ? ( - /* Split view: main agent (top) + subagent banner (bottom) */ - } - activeSessions={activeSubagents} - mainCursorMs={mainCursorMs} - liveFollow={replayMode === "follow"} - /> - ) : ( - - )} + {showFloatingInputOverlay && ( +
+ +
+ )} - {showFloatingInputOverlay && ( -
- + {showReplayBar && showMiniCPMStepExplanation && ( +
+ setShowMiniCPMStepExplanation(false)} + /> +
+ )}
- )} +
- {showReplayBar && showMiniCPMStepExplanation && ( -
- setShowMiniCPMStepExplanation(false)} - /> + {/* ── Dock (replay bar + app icons) ── */} + {showDock && ( +
+ {showReplayBar && ( +
+ +
+ )} + + +
)}
- {/* ── Dock (replay bar + app icons) ── */} - {showDock && ( -
- {showReplayBar && ( -
- -
- )} - - - -
- )} +
-
- - -
-
- ); -}); + + + ); + } +); ActivitySimulator.displayName = "ActivitySimulator"; export default ActivitySimulator; diff --git a/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx b/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx index 3f0257d8ec..c581b068d7 100644 --- a/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx +++ b/src/engines/Simulator/components/MusicPlayerReplayBar/index.tsx @@ -7,7 +7,7 @@ * playhead hiding) to the shared component so Kanban and Simulator * stay pixel-identical without code duplication. */ -import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import React, { memo, useCallback, useMemo, useRef, useState } from "react"; import ReplayProgressBar from "@src/components/ReplayProgressBar"; @@ -19,89 +19,131 @@ import { simulatorEventCountAtom, } from "@src/engines/SessionCore"; -const MusicPlayerReplayBar: React.FC = memo(() => { - const eventCount = useAtomValue(simulatorEventCountAtom); - const currentIndex = useAtomValue(currentSimulatorEventIndexAtom); - const navigateToIndex = useSetAtom(navigateToSimulatorEventByIndexAtom); - const [replayMode, setReplayMode] = useAtom(replayModeAtom); - - const [isDragging, setIsDragging] = useState(false); - const [dragValue, setDragValue] = useState(0); - const dragUpdateTimerRef = useRef(null); - - const sliderValue = useMemo(() => { - if (eventCount <= 1) return 0; - const safeIndex = Math.max(0, currentIndex); - return (safeIndex / (eventCount - 1)) * REPLAY_CONFIG.MAX_VALUE; - }, [currentIndex, eventCount]); - - const displayValue = isDragging ? dragValue : sliderValue; - - const sliderValueToIndex = useCallback( - (value: number): number => { - if (eventCount <= 1) return 0; - return Math.round((value / REPLAY_CONFIG.MAX_VALUE) * (eventCount - 1)); - }, - [eventCount] - ); +export interface MusicPlayerReplayBarViewProps { + eventCount: number; + currentIndex: number; + isFollowMode: boolean; + onNavigateToIndex: (index: number) => void; + onFollowLatest: () => void; + ariaLabel?: string; +} - const handleValueChange = useCallback( - (value: number | number[]) => { - const numVal = Array.isArray(value) ? value[0] : value; - setIsDragging(true); - setDragValue(numVal); - - if (dragUpdateTimerRef.current) { - clearTimeout(dragUpdateTimerRef.current); - } - dragUpdateTimerRef.current = setTimeout(() => { - const targetIndex = sliderValueToIndex(numVal); - navigateToIndex(targetIndex); - }, 16); - }, - [sliderValueToIndex, navigateToIndex] +/** + * Controlled replay scrubber shared by the desktop Simulator and remote Web + * sessions. State ownership stays with the host; drag behavior and rendering + * remain identical on both platforms. + */ +export const MusicPlayerReplayBarView: React.FC = + memo( + ({ + eventCount, + currentIndex, + isFollowMode, + onNavigateToIndex, + onFollowLatest, + ariaLabel, + }) => { + const [isDragging, setIsDragging] = useState(false); + const [dragValue, setDragValue] = useState(0); + const dragUpdateTimerRef = useRef(null); + + const sliderValue = useMemo(() => { + if (eventCount <= 1) return 0; + const safeIndex = Math.max(0, currentIndex); + return (safeIndex / (eventCount - 1)) * REPLAY_CONFIG.MAX_VALUE; + }, [currentIndex, eventCount]); + + const displayValue = isDragging ? dragValue : sliderValue; + + const sliderValueToIndex = useCallback( + (value: number): number => { + if (eventCount <= 1) return 0; + return Math.round( + (value / REPLAY_CONFIG.MAX_VALUE) * (eventCount - 1) + ); + }, + [eventCount] + ); + + const handleOnChange = useCallback( + (value: number | number[]) => { + const numVal = Array.isArray(value) ? value[0] : value; + setIsDragging(true); + setDragValue(numVal); + + if (dragUpdateTimerRef.current) { + clearTimeout(dragUpdateTimerRef.current); + } + dragUpdateTimerRef.current = setTimeout(() => { + const targetIndex = sliderValueToIndex(numVal); + onNavigateToIndex(targetIndex); + }, 16); + }, + [sliderValueToIndex, onNavigateToIndex] + ); + + // Drop-at-end snaps back to follow mode so new events auto-advance. + // Otherwise `navigateToSimulatorEventByIndexAtom` already sets the mode + // to "replay" (free browsing). + const handleOnAfterChange = useCallback( + (value: number | number[]) => { + const numVal = Array.isArray(value) ? value[0] : value; + + if (dragUpdateTimerRef.current) { + clearTimeout(dragUpdateTimerRef.current); + dragUpdateTimerRef.current = null; + } + + const targetIndex = sliderValueToIndex(numVal); + onNavigateToIndex(targetIndex); + + if (eventCount > 0 && targetIndex >= eventCount - 1) { + onFollowLatest(); + } + + setIsDragging(false); + }, + [sliderValueToIndex, onNavigateToIndex, onFollowLatest, eventCount] + ); + + React.useEffect(() => { + return () => { + if (dragUpdateTimerRef.current) { + clearTimeout(dragUpdateTimerRef.current); + } + }; + }, []); + + return ( + + ); + } ); - // Drop-at-end snaps back to follow mode so new events auto-advance. - // Otherwise `navigateToSimulatorEventByIndexAtom` already sets the mode - // to "replay" (free browsing). - const handleValueCommit = useCallback( - (value: number | number[]) => { - const numVal = Array.isArray(value) ? value[0] : value; - - if (dragUpdateTimerRef.current) { - clearTimeout(dragUpdateTimerRef.current); - dragUpdateTimerRef.current = null; - } +MusicPlayerReplayBarView.displayName = "MusicPlayerReplayBarView"; - const targetIndex = sliderValueToIndex(numVal); - navigateToIndex(targetIndex); - - if (eventCount > 0 && targetIndex >= eventCount - 1) { - setReplayMode("follow"); - } - - setIsDragging(false); - }, - [sliderValueToIndex, navigateToIndex, setReplayMode, eventCount] - ); - - React.useEffect(() => { - return () => { - if (dragUpdateTimerRef.current) { - clearTimeout(dragUpdateTimerRef.current); - } - }; - }, []); +const MusicPlayerReplayBar: React.FC = memo(() => { + const eventCount = useAtomValue(simulatorEventCountAtom); + const currentIndex = useAtomValue(currentSimulatorEventIndexAtom); + const navigateToIndex = useSetAtom(navigateToSimulatorEventByIndexAtom); + const replayMode = useAtomValue(replayModeAtom); + const setReplayMode = useSetAtom(replayModeAtom); return ( - setReplayMode("follow")} /> ); }); 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..06c98d3551 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts @@ -0,0 +1,233 @@ +/** @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, dispatch } 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/components/Placeholder", () => ({ + Placeholder: ({ + variant, + title, + subtitle, + onRetry, + }: { + variant: string; + title?: string; + subtitle?: string; + onRetry?: () => void; + }) => + React.createElement( + "div", + { "data-placeholder-variant": variant }, + title, + subtitle, + onRetry + ? React.createElement( + "button", + { "data-placeholder-retry": true, onClick: onRetry }, + "retry" + ) + : null + ), +})); + +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 streamed progress, empty, and retryable failure states", 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, + loadProgress: { loadedEvents: 2, totalEvents: 4 }, + }) + ); + expect( + root.container.querySelector( + "[data-testid='remote-workspace-streaming-progress']" + ) + ).not.toBeNull(); + expect(root.container.textContent).toContain( + "web.sessionPage.workstationLoading" + ); + expect( + root.container + .querySelector("[role='progressbar']") + ?.getAttribute("aria-valuenow") + ).toBe("50"); + + const onRetry = vi.fn(); + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [], + loadStatus: "error", + loadError: "Cloud request failed", + onRetry, + }) + ); + expect( + root.container.querySelector("[data-placeholder-variant='error']") + ).not.toBeNull(); + expect(root.container.textContent).toContain("Cloud request failed"); + await dispatch(() => + root.container + .querySelector("[data-placeholder-retry]") + ?.click() + ); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it("reveals the workspace after the first file page while progress continues", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [readEvent("export const partial = true;")], + loadStatus: "loading", + loadError: null, + loadProgress: { loadedEvents: 20, totalEvents: 100 }, + currentEventId: "read", + }) + ); + + expect( + root.container.querySelector("[data-remote-code-panel]")?.textContent + ).toBe("app.ts"); + expect( + root.container.querySelector( + "[data-testid='remote-workspace-streaming-banner']" + ) + ).not.toBeNull(); + expect( + root.container + .querySelector("[role='progressbar']") + ?.getAttribute("aria-valuenow") + ).toBe("20"); + }); +}); diff --git a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx new file mode 100644 index 0000000000..1509d026ce --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx @@ -0,0 +1,228 @@ +import { Loader2 } from "lucide-react"; +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 { + 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 { useRemoteSessionReplay } from "./useRemoteSessionReplay"; + +export interface RemoteSessionWorkspaceSurfaceProps { + events: SessionEvent[]; + loadStatus: SessionLoadStatus; + loadError: string | null; + loadProgress?: { + loadedEvents: number; + totalEvents: number | null; + } | null; + onRetry?: () => void; + /** 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, + loadProgress = null, + onRetry, + 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 totalEvents = loadProgress?.totalEvents ?? null; + const hasKnownTotal = totalEvents !== null; + const progressDetail = + loadProgress && hasKnownTotal + ? t("cloud.download.events", { + loaded: loadProgress.loadedEvents, + total: totalEvents, + }) + : null; + const progressPercent = + loadProgress && hasKnownTotal && totalEvents > 0 + ? Math.min( + 100, + Math.round((loadProgress.loadedEvents / totalEvents) * 100) + ) + : hasKnownTotal + ? 100 + : 0; + + const sidebar = useMemo( + () => ( + + ), + [currentEventId, replay, sidebarFileViewMode] + ); + + if (!replay.hasAnyOperations) { + const isLoading = loadStatus === "idle" || loadStatus === "loading"; + if (isLoading) { + return ( +
+
+ +
+ {t("web.sessionPage.workstationLoading")} +
+ + {progressDetail ? ( +
+ {progressDetail} +
+ ) : null} +
+
+ ); + } + return ( + + ); + } + + const mainContent = ( +
+ +
+ ); + + return ( +
+ {loadProgress ? ( +
+ +
+ {progressDetail ?? t("web.sessionPage.workstationLoading")} +
+
+ ) : null} + {loadStatus === "error" ? ( +
+ {t("web.sessionPage.workstationRefreshFailedBanner")} + {onRetry ? ( + + ) : null} +
+ ) : 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..d9499fa773 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx @@ -0,0 +1,163 @@ +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; + loadProgress?: { + loadedEvents: number; + totalEvents: number | null; + } | null; + onRetry?: () => void; + /** Replay cursor event forwarded to My Station file selection. */ + currentEventId?: string | null; + /** Inclusive replay cursor on the full event list. */ + 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, + loadProgress = null, + onRetry, + 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/SimulatorContentArea/SimulatorSingleView.test.ts b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.test.ts new file mode 100644 index 0000000000..5d58c3564c --- /dev/null +++ b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.test.ts @@ -0,0 +1,71 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { ReplayControlHostContext } from "../../context/ReplayControlHostContext"; +import { AppType } from "../../types/appTypes"; +import { SimulatorSingleView } from "./SimulatorSingleView"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/engines/SessionCore/hooks/session", () => ({ + useSessionId: () => ({ sessionId: "session-1" }), +})); + +vi.mock("@src/modules/WorkStation/shared", () => ({ + NoTabsPlaceholder: () => React.createElement("div"), +})); + +vi.mock("../FloatingReplayContainer", () => ({ + default: () => React.createElement("div", { "data-floating-replay": true }), +})); + +describe("SimulatorSingleView replay host ownership", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + const content = React.createElement("div", { "data-content": true }); + + it("keeps the desktop floating replay control by default", async () => { + const root = createSmokeRoot(); + roots.push(root); + + await root.render( + React.createElement(SimulatorSingleView, { + isBootingEvent: false, + mainContentAppType: AppType.CODE_EDITOR, + displayContent: content, + }) + ); + + expect( + root.container.querySelector("[data-floating-replay]") + ).not.toBeNull(); + }); + + it("hides the nested control when the Web host owns replay", async () => { + const root = createSmokeRoot(); + roots.push(root); + + await root.render( + React.createElement( + ReplayControlHostContext.Provider, + { value: true }, + React.createElement(SimulatorSingleView, { + isBootingEvent: false, + mainContentAppType: AppType.CODE_EDITOR, + displayContent: content, + }) + ) + ); + + expect(root.container.querySelector("[data-floating-replay]")).toBeNull(); + }); +}); diff --git a/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx index 91fe560503..506e884513 100644 --- a/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx +++ b/src/engines/Simulator/components/SimulatorContentArea/SimulatorSingleView.tsx @@ -12,13 +12,14 @@ * - the floating replay controls * - empty-state placeholder */ -import React from "react"; +import React, { useContext } from "react"; import { useTranslation } from "react-i18next"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; import { AppType } from "@src/engines/Simulator/types/appTypes"; import { NoTabsPlaceholder } from "@src/modules/WorkStation/shared"; +import { ReplayControlHostContext } from "../../context/ReplayControlHostContext"; import FloatingReplayContainer from "../FloatingReplayContainer"; interface SimulatorSingleViewProps { @@ -38,6 +39,7 @@ export const SimulatorSingleView: React.FC = ({ }) => { const { t } = useTranslation("sessions"); const { sessionId } = useSessionId(); + const replayControlOwnedByHost = useContext(ReplayControlHostContext); const hasSession = Boolean(sessionId); const showSessionPlaceholder = @@ -47,7 +49,10 @@ export const SimulatorSingleView: React.FC = ({ const showRounded = !hideHeader; const showFloatingReplayControls = - hasSession && mainContentAppType && mainContentAppType !== AppType.DIFF; + !replayControlOwnedByHost && + hasSession && + mainContentAppType && + mainContentAppType !== AppType.DIFF; return (
void; + onPlayPause: () => void; + onNext: () => void; + onPlaybackSpeedChange?: (speed: number) => void; + onEnterReplay: () => void; + onFollow: () => void; + /** Desktop-only replay filters. The Web adapter intentionally leaves these empty. */ + followOptions?: React.ReactNode; + replayOptions?: React.ReactNode; + /** Timestamp derives from the desktop SessionCore atoms and is omitted by Web. */ + showTimestamp?: boolean; +} + +/** + * Controlled visual contract for the Simulator replay pill. Desktop and Web + * provide different state adapters, but share the exact transport UI. + */ +export const SimulatorStatusBarView: React.FC = + memo( + ({ + replayMode, + eventCount, + isReplaying, + playbackSpeed, + onPrevious, + onPlayPause, + onNext, + onPlaybackSpeedChange, + onEnterReplay, + onFollow, + followOptions, + replayOptions, + showTimestamp = false, + }) => { + const { t } = useTranslation("sessions"); + const pillBgClass = + replayMode === "follow" ? "bg-primary-5" : SURFACE_TOKENS.surface; + + return ( +
+
+ {replayMode === "replay" && showTimestamp ? ( + + ) : null} + {replayMode === "follow" ? ( + <> + + {t("simulator.replay.followingAgent")} + + {followOptions} + {followOptions ? ( +
+ ) : null} + + } + position="top" + mouseEnterDelay={200} + framedPanel + > + + + + ) : ( + <> + + + + {playbackSpeed != null && onPlaybackSpeedChange != null ? ( + + ) : null} + {replayOptions} +
+ + + )} +
+
+ ); + } + ); + +SimulatorStatusBarView.displayName = "SimulatorStatusBarView"; diff --git a/src/engines/Simulator/components/SimulatorStatusBar/index.tsx b/src/engines/Simulator/components/SimulatorStatusBar/index.tsx index 1417148c16..3983252698 100644 --- a/src/engines/Simulator/components/SimulatorStatusBar/index.tsx +++ b/src/engines/Simulator/components/SimulatorStatusBar/index.tsx @@ -8,19 +8,8 @@ * Similar to Zoom's status bar at the bottom of meetings */ import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { - ChevronLeft, - ChevronRight, - MousePointer2, - Pause, - Play, -} from "lucide-react"; import React, { memo, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { KeyboardShortcutTooltipContent } from "@src/components/KeyboardShortcut"; -import Tooltip from "@src/components/Tooltip"; -import { SURFACE_TOKENS } from "@src/config/surfaceTokens"; import { REPLAY_CONFIG } from "@src/config/workspace/replayConfig"; import { currentEventIdAtom, @@ -38,14 +27,10 @@ import { import { EventFilterDropdown } from "./EventFilterDropdown"; import { FollowModeDropdown } from "./FollowModeDropdown"; -import { PlaybackSpeedInline } from "./PlaybackSpeedInline"; -import { ReplayTimestampSegment } from "./ReplayTimestampSegment"; -import { - STATUS_BAR_ICON_BTN_20, - STATUS_BAR_ICON_BTN_20_CIRCLE_NEUTRAL, - STATUS_BAR_ICON_BTN_20_CIRCLE_PRIMARY, - STATUS_BAR_TEXT_20, -} from "./tokens"; +import { SimulatorStatusBarView } from "./SimulatorStatusBarView"; + +export { SimulatorStatusBarView } from "./SimulatorStatusBarView"; +export type { SimulatorStatusBarViewProps } from "./SimulatorStatusBarView"; export interface SimulatorStatusBarProps { /** Callback when toggling between follow/free browsing */ @@ -67,8 +52,6 @@ export const SimulatorStatusBar: React.FC = memo( playbackSpeed, onPlaybackSpeedChange, }) => { - const { t } = useTranslation("sessions"); - const [replayMode, setReplayMode] = useAtom(replayModeAtom); const effectiveSimulatorEventIds = useAtomValue( effectiveSimulatorEventIdsAtom @@ -114,127 +97,27 @@ export const SimulatorStatusBar: React.FC = memo( onToggleMode, ]); - // In follow mode the entire pill is blue (single segment). In replay - // mode the pill is a single chat-surface coloured strip — the previous - // two-segment design (white controls + blue follow tail) read as two - // pills crammed together, which the user explicitly asked us to drop. - const pillBgClass = - replayMode === "follow" ? "bg-primary-5" : SURFACE_TOKENS.surface; - return ( -
-
- {replayMode === "replay" && } - {replayMode === "follow" ? ( - <> - {/* Follow mode is "always-follow-the-Agent" — there is no - target switching here. The per-app lock is a free-browse - concept and lives in the replay branch below. Static - text-only label; the `pl-1.5` keeps the text off the - pill's left edge when the leading Keyboard cluster is - hidden (chat visible). */} - - {t("simulator.replay.followingAgent")} - - -
- - } - position="top" - mouseEnterDelay={200} - framedPanel - > - - - - ) : replayMode === "replay" ? ( - <> - {/* Prev / Play / Next — then speed, then follow controls. */} - - - - {playbackSpeed != null && onPlaybackSpeedChange != null ? ( - - ) : null} - {/* Follow-target switch sits with the playback controls - (Prev/Play/Next/Speed/Switch). The 1px divider then - separates the "configure replay" cluster from the - "commit: enter follow mode" action on the right. */} - - -
- - - ) : null} -
-
+ navigatePrev()} + onPlayPause={() => onPlayPause?.()} + onNext={() => navigateNext()} + onPlaybackSpeedChange={onPlaybackSpeedChange} + onEnterReplay={handleToggleToReplay} + onFollow={handleToggleToFollow} + followOptions={} + replayOptions={ + <> + + + + } + showTimestamp + /> ); } ); 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/engines/Simulator/context/ReplayControlHostContext.ts b/src/engines/Simulator/context/ReplayControlHostContext.ts new file mode 100644 index 0000000000..b8d514e308 --- /dev/null +++ b/src/engines/Simulator/context/ReplayControlHostContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; + +/** True when a parent host (for example Cloud Web) owns replay transport UI. */ +export const ReplayControlHostContext = createContext(false); diff --git a/src/engines/Simulator/hooks/useSimulatorSubagents.ts b/src/engines/Simulator/hooks/useSimulatorSubagents.ts index 9f1eaca9ae..c37f489bf8 100644 --- a/src/engines/Simulator/hooks/useSimulatorSubagents.ts +++ b/src/engines/Simulator/hooks/useSimulatorSubagents.ts @@ -35,6 +35,7 @@ interface UseSimulatorSubagentsOptions { eventStoreVersion: number; currentEvent: SessionEvent | null; allEvents: SessionEvent[]; + enabled?: boolean; } function nonEmptyString(value: unknown): string | null { @@ -107,6 +108,7 @@ export function useSimulatorSubagents({ eventStoreVersion, currentEvent, allEvents, + enabled = true, }: UseSimulatorSubagentsOptions): UseSimulatorSubagentsReturn { const panelRevealRequest = useAtomValue(subagentPanelRevealRequestAtom); const focusedCellId = useAtomValue(focusedSubagentCellAtom); @@ -133,12 +135,12 @@ export function useSimulatorSubagents({ // DB query — re-triggered by eventStoreVersion (bumped on every EventStore // mutation, including args patches like stamp_subagent_session_id_on_parent). const dbSubagentSessions = useSubagentSessions( - sessionId || null, + enabled ? sessionId || null : null, eventStoreVersion ); const eventSubagentSessions = useMemo( - () => fallbackSubagentSessionsFromEvents(allEvents), - [allEvents] + () => (enabled ? fallbackSubagentSessionsFromEvents(allEvents) : []), + [allEvents, enabled] ); const allSubagentSessions = useMemo(() => { if (eventSubagentSessions.length === 0) return dbSubagentSessions; diff --git a/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx new file mode 100644 index 0000000000..adfa96fa4a --- /dev/null +++ b/src/features/Org2Cloud/SessionComments/CommentThreadList.tsx @@ -0,0 +1,879 @@ +/** + * CommentThreadList — presentational thread list + composer shared by the + * turn-anchored inline panels and the session-level notes dialog (design + * managed-cloud collaboration design). + * + * PR-review semantics: flat threads (top-level + one reply level), a + * three-state status on thread heads (Active / Resolved / Won't fix), edit + * gated to the author, delete to author/org-admin, tombstones rendered as + * "comment deleted" so reply chains keep their anchor. The anchor itself + * (event id / session-level) is baked into `onAdd` by the caller — this + * component never sees it. + * + * Draft restore (design §4 non-goals): composers clear ONLY on a + * successful add/edit — a failed RPC keeps the text in place and surfaces + * a toast, which is the local equivalent of the `restoreToInputAtom` + * cancel-restore pattern (no cross-component atom needed: the composer + * state never left this component). + * + * Agent surface: follow-ups run IN PLACE on the owning session. The literal + * `@agent ` prefix on the TOP-LEVEL composer runs a personal scoped round + * (comment-first — the comment posts verbatim through the untouched add + * path, then the round fires), `kind='agent_report'` replies render as + * ordinary replies with a tiny agent affix, and a thread whose round is live + * shows one minimal "Agent is addressing…" line. + */ +import { AtSign, Bot, Check, Loader2, Pencil, Trash2 } from "lucide-react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import ComposerSurface from "@src/components/ComposerSurface"; +import { PILL_CONTROL_ACTIVE_ACCENT_CLASS } from "@src/components/CompoundPill/config"; +import Dropdown from "@src/components/Dropdown"; +import { + DROPDOWN_CLASSES, + DROPDOWN_WIDTHS, +} from "@src/components/Dropdown/tokens"; +import Message from "@src/components/Message"; +import Tooltip from "@src/components/Tooltip"; +import { MarkdownContent } from "@src/modules/shared/components/MarkdownContent"; +import MarkdownTextareaEditor, { + type MarkdownEditorMode, + type MarkdownTextareaEditorRef, +} from "@src/modules/shared/components/MarkdownTextareaEditor"; +import MarkdownEditorModeSwitch from "@src/modules/shared/components/MarkdownTextareaEditor/ModeSwitch"; +import { formatRelativeTime } from "@src/util/time/formatRelativeTime"; + +import type { CloudOrgMember } from "../org2CloudClient"; +import { + CLOUD_COMMENT_MAX_BODY_LENGTH, + type CloudCommentResolution, + type CloudSessionComment, +} from "../org2CloudCommentsClient"; +import { + type CommentThread, + getThreadResolution, + isThreadResolved, +} from "../org2CloudSessionCommentsAtom"; +import { useSessionCommentsContext } from "./SessionCommentsContext"; +import { + AGENT_COMPOSER_PREFIX, + detectAgentPrefix, + shouldShowAgentSuggestion, + splitAgentMentionBody, +} from "./commentAgentAffordances"; + +export type CommentThreadStatus = "active" | CloudCommentResolution; + +interface ResolvedMention { + id: string; + name: string; +} + +function resolveMentions( + mentionedUserIds: readonly string[], + members: readonly CloudOrgMember[] +): ResolvedMention[] { + const nameById = new Map( + members.map((member) => [ + member.userId, + member.displayName ?? member.userId, + ]) + ); + return mentionedUserIds.map((id) => ({ + id, + name: nameById.get(id) ?? id, + })); +} + +const MemberMentionChip: React.FC< + ResolvedMention & { dataTestId?: string } +> = ({ name, dataTestId }) => ( + + @{name} + +); + +const THREAD_STATUS_OPTIONS: readonly CommentThreadStatus[] = [ + "active", + "resolved", + "wont_fix", +]; + +const THREAD_STATUS_LABEL_KEYS: Record = { + active: "cloud.comments.statusActive", + resolved: "cloud.comments.resolved", + wont_fix: "cloud.comments.wontFix", +}; + +const MEMBER_MENTION_DROPDOWN_CLASS = `${DROPDOWN_CLASSES.panel} ${DROPDOWN_WIDTHS.fileTreeClass} flex flex-col`; + +export interface CommentThreadListProps { + threads: CommentThread[]; + viewerUserId: string | null; + viewerIsAdmin: boolean; + /** Read-only surfaces hide compose/reply/status controls. */ + readOnly?: boolean; + /** Hide the top-level composer (e.g. the orphaned "earlier version" + * bucket, where new anchors would be meaningless). Replies stay. */ + showComposer?: boolean; + /** Disable the TOP-LEVEL composer with a tooltip (replay-access gate). */ + composerDisabled?: boolean; + composerDisabledReason?: string; + composerPlaceholder?: string; + /** Optional top-level composer cancel action (inline panels use it to close). */ + onComposerCancel?: () => void; + emptyLabel?: string; + /** Explicit override for header dialogs mounted outside the provider tree. */ + mentionableMembers?: readonly CloudOrgMember[]; + /** + * Resolves with the created row when the caller's add path returns it + * (context surfaces do) — the `@agent ` prefix needs the new comment's + * id. Undefined = row unknown; the prefix silently skips (comment-first, + * never comment-blocking). + */ + onAdd: ( + body: string, + parentId?: string, + mentionedUserIds?: string[] + ) => Promise; + onEdit: (commentId: string, body: string) => Promise; + onDelete: (commentId: string) => Promise; + onResolve: ( + commentId: string, + resolved: boolean, + resolution?: CloudCommentResolution + ) => Promise; +} + +interface ComposerProps { + placeholder: string; + submitLabel: string; + autoFocus?: boolean; + disabled?: boolean; + allowAgentMention?: boolean; + mentionableMembers?: readonly CloudOrgMember[]; + onSubmit: (body: string, mentionedUserIds: string[]) => Promise; + onCancel?: () => void; + testId?: string; +} + +/** Clears only on success — a failed submit keeps the draft in place. */ +const CommentComposer: React.FC = ({ + placeholder, + submitLabel, + autoFocus = false, + disabled = false, + allowAgentMention = false, + mentionableMembers = [], + onSubmit, + onCancel, + testId, +}) => { + const { t } = useTranslation("navigation"); + const [body, setBody] = useState(""); + const [mentionedUserIds, setMentionedUserIds] = useState([]); + const [mentionDropdownOpen, setMentionDropdownOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [editorMode, setEditorMode] = useState("write"); + const editorRef = useRef(null); + const trimmed = body.trim(); + const showAgentSuggestion = + allowAgentMention && shouldShowAgentSuggestion(body); + const mentionOptions = useMemo( + () => + mentionableMembers.map((member) => ({ + value: member.userId, + label: member.displayName ?? member.userId, + dataTestId: `session-comment-mention-${member.userId}`, + })), + [mentionableMembers] + ); + const mentionedNames = useMemo( + () => resolveMentions(mentionedUserIds, mentionableMembers), + [mentionableMembers, mentionedUserIds] + ); + + const submit = useCallback(async () => { + if (!trimmed || busy || disabled) return; + setBusy(true); + try { + await onSubmit(trimmed, mentionedUserIds); + setBody(""); + setMentionedUserIds([]); + setEditorMode("write"); + } catch { + // Draft restore: the text stays in the composer. + Message.error(t("cloud.comments.addError")); + } finally { + setBusy(false); + } + }, [trimmed, busy, disabled, mentionedUserIds, onSubmit, t]); + + const mentionActions = + mentionOptions.length > 0 ? ( +
+ document.body} + position="top-end" + avoidViewportOverflow + onVisibleChange={setMentionDropdownOpen} + onSelect={(value) => + setMentionedUserIds(Array.isArray(value) ? value.map(String) : []) + } + > + + + {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" + /> +
+ ) : ( + + ))} +
+ ); +}; + +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 && ( + + )} + {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/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/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/org2CloudOrgsAtom.test.ts b/src/features/Org2Cloud/org2CloudOrgsAtom.test.ts index c80c1060f0..1f484a0444 100644 --- a/src/features/Org2Cloud/org2CloudOrgsAtom.test.ts +++ b/src/features/Org2Cloud/org2CloudOrgsAtom.test.ts @@ -1,16 +1,25 @@ +// @vitest-environment jsdom import { createStore } from "jotai"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { beginOrg2CloudOrgsRequest, commitOrg2CloudOrgsRequest, + failOrg2CloudOrgsRequest, getSidebarActiveCloudOrg, isOrg2CloudOrgsConverging, + markOrg2CloudOrgsRequestRetrying, org2CloudOrgsAtom, + org2CloudOrgsLoadStateAtom, org2CloudOrgsLoadedAtom, queueOrg2CloudOrgsConvergence, + scheduleVisibleOrg2CloudOrgsRetry, } from "./org2CloudOrgsAtom"; +afterEach(() => { + vi.useRealTimers(); +}); + describe("org2 cloud roster request ordering", () => { it("resolves sharing controls only for the exact active cloud org", () => { const orgs = [ @@ -45,6 +54,7 @@ describe("org2 cloud roster request ordering", () => { "team", ]); expect(store.get(org2CloudOrgsLoadedAtom)).toBe(true); + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("ready"); }); it("invalidates an in-flight roster read when auth is cleared", () => { @@ -63,6 +73,64 @@ describe("org2 cloud roster request ordering", () => { expect(store.get(org2CloudOrgsLoadedAtom)).toBe(false); }); + it("exposes retrying and terminal failure without authorizing an empty roster", () => { + const store = createStore(); + const request = beginOrg2CloudOrgsRequest(store); + + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("loading"); + expect(markOrg2CloudOrgsRequestRetrying(store, request)).toBe(true); + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("retrying"); + expect(failOrg2CloudOrgsRequest(store, request)).toBe(true); + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("error"); + expect(store.get(org2CloudOrgsLoadedAtom)).toBe(false); + expect(store.get(org2CloudOrgsAtom)).toEqual([]); + }); + + it("does not let a stale failure overwrite a newer successful roster", () => { + const store = createStore(); + const staleRequest = beginOrg2CloudOrgsRequest(store); + const currentRequest = beginOrg2CloudOrgsRequest(store); + + commitOrg2CloudOrgsRequest(store, currentRequest, [ + { orgId: "team", name: "Team", role: "member" }, + ]); + + expect(failOrg2CloudOrgsRequest(store, staleRequest)).toBe(false); + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("ready"); + expect(store.get(org2CloudOrgsAtom)).toHaveLength(1); + }); + + it("pauses a first-load retry while hidden and revalidates once on return", () => { + vi.useFakeTimers(); + let visibilityState: DocumentVisibilityState = "hidden"; + const originalDescriptor = Object.getOwnPropertyDescriptor( + document, + "visibilityState" + ); + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + const retry = vi.fn(); + + const dispose = scheduleVisibleOrg2CloudOrgsRetry(2_000, retry); + vi.advanceTimersByTime(10_000); + expect(retry).not.toHaveBeenCalled(); + + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + expect(retry).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(10_000); + expect(retry).toHaveBeenCalledTimes(1); + + dispose(); + if (originalDescriptor) { + Object.defineProperty(document, "visibilityState", originalDescriptor); + } else { + Reflect.deleteProperty(document, "visibilityState"); + } + }); + it("serializes mutation convergence and exposes its priority window", async () => { const store = createStore(); let releaseFirst!: () => void; diff --git a/src/features/Org2Cloud/org2CloudOrgsAtom.ts b/src/features/Org2Cloud/org2CloudOrgsAtom.ts index 7f03d88d6a..3aa06c1f66 100644 --- a/src/features/Org2Cloud/org2CloudOrgsAtom.ts +++ b/src/features/Org2Cloud/org2CloudOrgsAtom.ts @@ -13,7 +13,7 @@ * Cleared to `[]` on sign-out. Offline / fetch failure degrades to `[]` (no * crash, no stale cache). */ -import { atom, createStore, useAtom, useStore } from "jotai"; +import { atom, createStore, useAtom, useAtomValue, useStore } from "jotai"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; @@ -95,6 +95,57 @@ export interface RefetchOrg2CloudOrgsOptions { maxAttempts?: number; } +/** + * Keep the bounded first-load retry dormant while the document is hidden, + * then revalidate once immediately on return. The returned disposer owns + * both the timeout and visibility listener. + */ +export function scheduleVisibleOrg2CloudOrgsRetry( + delayMs: number, + retry: () => void +): () => void { + let disposed = false; + let timer: ReturnType | null = null; + const ownerDocument = typeof document === "undefined" ? null : document; + + const clearTimer = () => { + if (timer === null) return; + clearTimeout(timer); + timer = null; + }; + const dispose = () => { + if (disposed) return; + disposed = true; + clearTimer(); + ownerDocument?.removeEventListener("visibilitychange", onVisibilityChange); + }; + const run = () => { + if (disposed) return; + dispose(); + retry(); + }; + const scheduleTimer = () => { + if (disposed || timer !== null) return; + timer = setTimeout(run, delayMs); + }; + function onVisibilityChange(): void { + if (ownerDocument?.visibilityState === "hidden") { + clearTimer(); + return; + } + run(); + } + + if (ownerDocument) { + ownerDocument.addEventListener("visibilitychange", onVisibilityChange); + if (ownerDocument.visibilityState !== "hidden") scheduleTimer(); + } else { + scheduleTimer(); + } + + return dispose; +} + export const org2CloudOrgsAtom = atom([]); org2CloudOrgsAtom.debugLabel = "org2CloudOrgsAtom"; @@ -111,6 +162,25 @@ org2CloudOrgsAtom.debugLabel = "org2CloudOrgsAtom"; export const org2CloudOrgsLoadedAtom = atom(false); org2CloudOrgsLoadedAtom.debugLabel = "org2CloudOrgsLoadedAtom"; +/** + * User-visible lifecycle of the authoritative cloud-org roster request. + * + * `org2CloudOrgsLoadedAtom` remains the membership safety gate: it only turns + * true after a successful server response. This state adds the missing + * presentation distinction between an in-flight/retrying request and a + * terminal transport failure, so Web never turns a failed first load into a + * permanent spinner or an authoritatively empty roster. + */ +export type Org2CloudOrgsLoadState = + | "idle" + | "loading" + | "retrying" + | "ready" + | "error"; + +export const org2CloudOrgsLoadStateAtom = atom("idle"); +org2CloudOrgsLoadStateAtom.debugLabel = "org2CloudOrgsLoadStateAtom"; + /** * Monotonic request generation for every `list_my_orgs` caller. Realtime can * start a roster read from the membership event before the mutation RPC has @@ -166,6 +236,7 @@ export async function queueOrg2CloudOrgsConvergence( export function beginOrg2CloudOrgsRequest(store: JotaiStore): number { const epoch = store.get(org2CloudOrgsRequestEpochAtom) + 1; store.set(org2CloudOrgsRequestEpochAtom, epoch); + store.set(org2CloudOrgsLoadStateAtom, "loading"); return epoch; } @@ -184,6 +255,25 @@ export function commitOrg2CloudOrgsRequest( if (!isCurrentOrg2CloudOrgsRequest(store, epoch)) return false; store.set(org2CloudOrgsAtom, orgs); store.set(org2CloudOrgsLoadedAtom, true); + store.set(org2CloudOrgsLoadStateAtom, "ready"); + return true; +} + +export function markOrg2CloudOrgsRequestRetrying( + store: JotaiStore, + epoch: number +): boolean { + if (!isCurrentOrg2CloudOrgsRequest(store, epoch)) return false; + store.set(org2CloudOrgsLoadStateAtom, "retrying"); + return true; +} + +export function failOrg2CloudOrgsRequest( + store: JotaiStore, + epoch: number +): boolean { + if (!isCurrentOrg2CloudOrgsRequest(store, epoch)) return false; + store.set(org2CloudOrgsLoadStateAtom, "error"); return true; } @@ -254,6 +344,8 @@ export function parseCloudOrgSelectorValue(value: string): string | null { */ export function useOrg2CloudOrgs(): void { const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const orgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); + const orgsLoadState = useAtomValue(org2CloudOrgsLoadStateAtom); const store = useStore(); const refetchOrgs = useRefetchOrg2CloudOrgs(); const authRef = useRef(auth); @@ -270,12 +362,13 @@ export function useOrg2CloudOrgs(): void { beginOrg2CloudOrgsRequest(store); store.set(org2CloudOrgsAtom, []); store.set(org2CloudOrgsLoadedAtom, false); + store.set(org2CloudOrgsLoadStateAtom, authIdentityKey ? "loading" : "idle"); }, [authIdentityKey, store]); useEffect(() => { if (!authIdentityKey) return; let cancelled = false; - let retryTimer: ReturnType | null = null; + let cancelRetrySchedule: (() => void) | null = null; // Bounded auto-retry with backoff. A TRANSIENT token-refresh / // list_my_orgs failure otherwise degrades the roster to `[]` with the // loaded flag stuck FALSE — cloud orgs SILENTLY vanish from the org @@ -289,11 +382,18 @@ export function useOrg2CloudOrgs(): void { const current = authRef.current; if (!current || cancelled) return; const requestEpoch = beginOrg2CloudOrgsRequest(store); - const retry = (): void => { - if (cancelled || attempt >= RETRY_DELAYS_MS.length) return; - retryTimer = setTimeout(() => { - void runAttempt(attempt + 1); - }, RETRY_DELAYS_MS[attempt]); + const retry = (): boolean => { + if (cancelled || attempt >= RETRY_DELAYS_MS.length) return false; + markOrg2CloudOrgsRequestRetrying(store, requestEpoch); + cancelRetrySchedule?.(); + cancelRetrySchedule = scheduleVisibleOrg2CloudOrgsRetry( + RETRY_DELAYS_MS[attempt], + () => { + cancelRetrySchedule = null; + void runAttempt(attempt + 1); + } + ); + return true; }; let refreshRejected = false; const fresh = await ensureFreshSession(current, { @@ -321,7 +421,9 @@ export function useOrg2CloudOrgs(): void { } if (!fresh) { log.warn("cloud org fetch skipped: token refresh failed"); - if (!refreshRejected) retry(); + if (!refreshRejected && !retry()) { + failOrg2CloudOrgsRequest(store, requestEpoch); + } return; } commitRefreshedAuth(setAuth, current, fresh); @@ -343,7 +445,7 @@ export function useOrg2CloudOrgs(): void { // membership-pending — blocked from an unarbitrated start), and // retry so it recovers without waiting for the next sign-in. store.set(org2CloudOrgsAtom, []); - retry(); + if (!retry()) failOrg2CloudOrgsRequest(store, requestEpoch); return; } if (!commitOrg2CloudOrgsRequest(store, requestEpoch, orgs)) return; @@ -360,19 +462,26 @@ export function useOrg2CloudOrgs(): void { void runAttempt(0); return () => { cancelled = true; - if (retryTimer) clearTimeout(retryTimer); + cancelRetrySchedule?.(); }; }, [authIdentityKey, setAuth, store]); useEffect(() => { - if (!authIdentityKey) return undefined; + // The first-load retry owner above is the only requester until it reaches + // success or a terminal error. Starting the focus/visibility convergence + // owner during backoff would let the same visibility edge launch two + // equivalent roster reads. After either terminal state, the shared + // refetch coordinator owns all ongoing/manual convergence requests. + if (!authIdentityKey || (!orgsLoaded && orgsLoadState !== "error")) { + return undefined; + } return startOrg2CloudRosterConvergence({ refresh: refetchOrgs, onError: (error) => { log.warn("cloud org convergence refresh failed", error); }, }); - }, [authIdentityKey, refetchOrgs]); + }, [authIdentityKey, orgsLoadState, orgsLoaded, refetchOrgs]); } /** @@ -407,6 +516,7 @@ export function useRefetchOrg2CloudOrgs(): ( if (!current) { store.set(org2CloudOrgsAtom, []); store.set(org2CloudOrgsLoadedAtom, false); + store.set(org2CloudOrgsLoadStateAtom, "idle"); return []; } const fresh = await ensureFreshSession(current); @@ -414,6 +524,7 @@ export function useRefetchOrg2CloudOrgs(): ( latest = store.get(org2CloudOrgsAtom); } else if (!fresh) { log.warn("cloud org refetch skipped: token refresh failed"); + failOrg2CloudOrgsRequest(store, requestEpoch); latest = []; } else { commitRefreshedAuth(setAuth, current, fresh); @@ -421,6 +532,7 @@ export function useRefetchOrg2CloudOrgs(): ( if (!isCurrentOrg2CloudOrgsRequest(store, requestEpoch)) { latest = store.get(org2CloudOrgsAtom); } else if (orgs === null) { + failOrg2CloudOrgsRequest(store, requestEpoch); latest = []; } else if (commitOrg2CloudOrgsRequest(store, requestEpoch, orgs)) { latest = orgs; 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/i18n/index.test.ts b/src/i18n/index.test.ts new file mode 100644 index 0000000000..131325964e --- /dev/null +++ b/src/i18n/index.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resolveSystemLanguage } from "./index"; + +function useBrowserLanguages(language: string, languages = [language]): void { + vi.stubGlobal("navigator", { language, languages }); +} + +describe("resolveSystemLanguage", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW"])( + "maps %s to Traditional Chinese", + (language) => { + useBrowserLanguages(language); + + expect(resolveSystemLanguage()).toBe("zh-Hant"); + } + ); + + it.each(["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"])( + "keeps %s on Simplified Chinese", + (language) => { + useBrowserLanguages(language); + + expect(resolveSystemLanguage()).toBe("zh"); + } + ); + + it.each([ + ["fr-CA", "fr"], + ["pt-BR", "pt"], + ["ja-JP", "ja"], + ] as const)( + "resolves %s through its supported base locale", + (language, expected) => { + useBrowserLanguages(language); + + expect(resolveSystemLanguage()).toBe(expected); + } + ); + + it("continues through the browser preference list after an unsupported locale", () => { + useBrowserLanguages("xx-ZZ", ["xx-ZZ", "zh-HK", "en-US"]); + + expect(resolveSystemLanguage()).toBe("zh-Hant"); + }); + + it("falls back to English when no browser locale is supported", () => { + useBrowserLanguages("xx-ZZ", ["xx-ZZ", "yy-AA"]); + + expect(resolveSystemLanguage()).toBe("en"); + }); +}); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index ffb7b95921..0a10764d35 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -136,11 +136,23 @@ export function resolveSystemLanguage(): SupportedLanguage { ); for (const browserLanguage of browserLanguages) { - if (isSupportedLanguage(browserLanguage)) { - return browserLanguage; + const normalizedLanguage = browserLanguage.trim().replace(/_/g, "-"); + const subtags = normalizedLanguage.toLowerCase().split("-"); + const baseLanguage = subtags[0]; + + if (baseLanguage === "zh") { + const script = subtags.find( + (subtag: string) => subtag === "hans" || subtag === "hant" + ); + if (script === "hant") return "zh-Hant"; + if (script === "hans") return "zh"; + + const region = subtags.find((subtag: string) => + ["tw", "hk", "mo"].includes(subtag) + ); + return region ? "zh-Hant" : "zh"; } - const baseLanguage = browserLanguage.split("-")[0]; if (isSupportedLanguage(baseLanguage)) { return baseLanguage; } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 4c0dafc6e5..69ccd8fedb 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -2704,6 +2704,16 @@ "noRepo": "Kein repo" } }, + "notifications": { + "backgroundSession": "Hintergrund-Session", + "taskCompletedTitle": "Aufgabe abgeschlossen", + "taskCompletedBody": "„{{name}}“ wurde abgeschlossen – bereit zur Überprüfung", + "taskCompletedToast": "„{{name}}“ wurde abgeschlossen. Öffnen Sie die Session, um das Ergebnis zu überprüfen.", + "openSessionAction": "Session öffnen", + "taskFailedTitle": "Aufgabe fehlgeschlagen", + "taskFailedBody": "„{{name}}“ ist fehlgeschlagen{{detail}}", + "taskCancelledToast": "„{{name}}“ wurde abgebrochen" + }, "globalToolbar": { "selectWorkspaceToStart": "Workspace auswählen, um zu starten", "selectRepoToStart": "Repo auswählen, um zu starten" diff --git a/src/i18n/locales/de/integrations.json b/src/i18n/locales/de/integrations.json index d254c394e5..49c8464811 100644 --- a/src/i18n/locales/de/integrations.json +++ b/src/i18n/locales/de/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} erfolgreich aktualisiert", "refreshFailed": "{{name}} Validierung fehlgeschlagen - Anmeldedaten prüfen", "allRefreshed": "Alle Konten aktualisiert", - "refreshError": "{{name}} konnte nicht aktualisiert werden", + "refreshError": "{{name}} konnte nicht aktualisiert werden: {{error}}", "localRemoved": "{{name}} entfernt", "unlistPending": "{{name}} Entfernung angefordert. Wird nach Ende aktiver Session entfernt.", "listingRemoved": "{{name}} vom Marktplatz entfernt", diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index eac5b5a5b0..bbed3fadda 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -129,9 +129,9 @@ }, "folderCounts": { "repo": "{{count}} Repo", - "repo_plural": "{{count}} Repos", "multiRepoWorkspace": "{{count}} Multi-Repo-Workspace", - "multiRepoWorkspace_plural": "{{count}} Multi-Repo-Workspaces" + "repo_other": "{{count}} Repos", + "multiRepoWorkspace_other": "{{count}} Multi-Repo-Workspaces" }, "bottomBar": { "settings": "Einstellungen", diff --git a/src/i18n/locales/de/sessions.json b/src/i18n/locales/de/sessions.json index fc02c1e499..96b51c6b02 100644 --- a/src/i18n/locales/de/sessions.json +++ b/src/i18n/locales/de/sessions.json @@ -2426,7 +2426,10 @@ "memberSessions": "Mitgliedersitzungen", "pauseRun": "Ausführung pausieren", "resumeRun": "Ausführung fortsetzen", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plan zur Genehmigung bereit" + } }, "agentOrgInbox": { "title": "Agent-Nachrichten", diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 3525a3972f..ca6412b8f1 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -732,7 +732,8 @@ "sent": "Testbenachrichtigung gesendet", "permissionWarning": "Testbenachrichtigung konnte nicht gesendet werden. Berechtigungen prüfen.", "sendFailed": "Testbenachrichtigung konnte nicht gesendet werden. Bitte Berechtigungen prüfen und erneut versuchen.", - "soundFailed": "Der Benachrichtigungston konnte nicht wiedergegeben werden. Prüfen Sie die Systemlautstärke." + "soundFailed": "Der Benachrichtigungston konnte nicht wiedergegeben werden. Prüfen Sie die Systemlautstärke.", + "body": "Dies ist eine Testbenachrichtigung von ORGII" } }, "editor": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 7847f69fc5..c556510eee 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -263,7 +263,9 @@ "skipThisVersion": "Skip this version", "later": "Later", "link": "Link", - "copyName": "Copy Name" + "copyName": "Copy Name", + "hide": "Hide file content", + "show": "Show file content" }, "refreshToast": { "successName": "{{name}} refreshed", @@ -554,7 +556,10 @@ "typeFilePath": "Type a file path", "appliedCommits": "Applied commits", "noAppliedCommit": "No linked commit yet", - "noSessionsForFile": "No sessions found for this file" + "noSessionsForFile": "No sessions found for this file", + "actions": "Actions", + "all": "All", + "email": "Email" }, "searchModes": { "keywordLabel": "Keyword", @@ -665,7 +670,8 @@ }, "imageUploadFailed": "Failed to upload image", "imageTooLarge": "Image is too large", - "unknownError": "Unknown error occurred" + "unknownError": "Unknown error occurred", + "openInFinderFailed": "Failed to open in Finder" }, "confirmation": { "delete": "Are you sure you want to delete this?", @@ -1081,7 +1087,9 @@ "startBrowsingMarket": "Start browsing the marketplace", "previewUnavailable": "Preview unavailable", "selectPrCommitToViewDiff": "Select a pull request commit from the header to view its diff.", - "selectRepositoryFromWorkspace": "Choose a workspace repository" + "selectRepositoryFromWorkspace": "Choose a workspace repository", + "noSessionHistory": "No session history", + "noSessionHistoryForFile": "No session activity found for {{file}}" }, "inbox": { "searchPlaceholder": "Search messages...", @@ -1479,7 +1487,14 @@ }, "commits": { "backToList": "All commits", - "none": "No commits" + "none": "No commits", + "committed": "committed", + "copySha": "Copy commit SHA", + "onDate": "Commits on {{date}}", + "unknownDate": "Unknown date", + "verified": "Verified", + "viewCommit": "View commit {{sha}}: {{summary}}", + "viewDetails": "View commit details" }, "changes": { "title": "Files changed", @@ -1506,7 +1521,9 @@ "authRequired": { "title": "Connect GitHub", "description": "Go to Settings → Connections to connect your GitHub account." - } + }, + "failedToLoad": "Failed to load pull requests", + "navigationTrail": "Pull request navigation" }, "issues": { "title": "Issues", @@ -1621,7 +1638,8 @@ }, "reAuthRequired": "GitHub Authorization Required", "reAuthDescription": "Your GitHub token has expired. Go to Settings → Connections to reconnect.", - "goToSettings": "Go to Settings" + "goToSettings": "Go to Settings", + "loadingTimeline": "Loading activity…" }, "commit": { "copySha": "Copy SHA", @@ -1827,7 +1845,6 @@ "switchNo": "No, use for this session only", "switchWorkspace": "Switch workspace", "switchBranch": "Switch branch", - "selectWorktreeSource": "Select worktree branch or pull request", "switchLocation": "Switch running location", "sessionWorkspace": "Switch Session Workspace", "locationAria": "Select running location", @@ -1840,7 +1857,8 @@ "label": "Documents", "description": "Documents folder" } - } + }, + "selectWorktreeSource": "Select worktree branch or pull request" }, "spotlightFooter": { "navigate": "Navigate", @@ -2396,7 +2414,8 @@ "skills": "Skills", "mcp": "MCP", "subagents": "Subagents", - "summarized": "Summarized conversation" + "summarized": "Summarized conversation", + "unattributed": "Unattributed" }, "warningHint": "Context is filling up. Start a new session soon for best results.", "dangerHint": "Context is nearly full. Start a new session to avoid losing detail.", @@ -2441,7 +2460,9 @@ "today": "Today", "yesterday": "Yesterday", "tomorrow": "Tomorrow", - "daysAgo": "{{count}} days ago" + "daysAgo": "{{count}} days ago", + "last7Days": "Last 7 days", + "last30Days": "Last 30 days" }, "cloneForm": { "titleCloneFromGitHub": "Clone from GitHub", @@ -2920,6 +2941,143 @@ "preparingDownload": "Preparing download…", "downloadingAndInstalling": "Downloading and installing update (v{{version}})…" }, + "navigation": { + "section": "Section {{current}}", + "goToSection": "Go to {{label}}, section {{current}} of {{total}}", + "sectionPosition": "Section {{current}} of {{total}}" + }, + "gitDialogs": { + "common": { + "cancel": "Cancel", + "ok": "OK", + "currentBranch": "current branch", + "gitSaid": "Git said: {{message}}" + }, + "operations": { + "push": "Push", + "pull": "Pull", + "fetch": "Fetch", + "checkout": "Checkout", + "sync": "Sync", + "merge": "Merge", + "rebase": "Rebase", + "commit": "Commit", + "clone": "Clone", + "other": "{{operation}}" + }, + "checkoutBlocked": { + "worktreeTitle": "Branch Already Open in Another Worktree", + "mergeTitle": "Finish the Merge First", + "rebaseTitle": "Finish the Rebase First", + "cherryPickTitle": "Finish the Cherry-pick First", + "branchNotFoundTitle": "Branch Not Found", + "cannotSwitchTitle": "Cannot Switch Branch", + "worktreeMessage": "Cannot switch to \"{{branchName}}\" because Git says that branch is already checked out in another worktree.\n\nOpen that worktree from Source Control, or choose a different branch for this workspace.", + "mergeMessage": "Cannot switch to \"{{branchName}}\" while a merge is in progress.\n\nResolve the merge, then continue or abort it before switching branches.", + "rebaseMessage": "Cannot switch to \"{{branchName}}\" while a rebase is in progress.\n\nContinue or abort the rebase before switching branches.", + "cherryPickMessage": "Cannot switch to \"{{branchName}}\" while a cherry-pick is in progress.\n\nContinue or abort the cherry-pick before switching branches.", + "branchNotFoundMessage": "Branch \"{{branchName}}\" was not found. Fetch remote branches, then try again.", + "cannotSwitchMessage": "Cannot switch to \"{{branchName}}\"." + }, + "checkoutConflict": { + "title": "Checkout Conflict", + "message": "Local changes would be overwritten when checking out \"{{branchName}}\".", + "stashAndCheckout": "Stash & Checkout", + "discardAndCheckout": "Discard & Checkout" + }, + "detachedHead": { + "title": "Detached HEAD State", + "message": "You are in detached HEAD state at commit {{shortHash}}.\n\nYou are not on any branch. Any commits you make may be lost if you check out another branch without creating a new branch first.\n\n⚠️ Commits in detached HEAD state may be garbage collected.", + "createBranch": "Create Branch", + "continueWithoutBranch": "Continue Without Branch" + }, + "largePush": { + "title": "Large Push Detected", + "message_one": "You are about to push {{count}} commit to {{remoteName}}/{{branchName}}.\n\nThis is more than usual. Are you sure you want to continue?\n\n💡 Consider breaking large changes into smaller, more focused commits.", + "message_other": "You are about to push {{count}} commits to {{remoteName}}/{{branchName}}.\n\nThis is more than usual. Are you sure you want to continue?\n\n💡 Consider breaking large changes into smaller, more focused commits.", + "pushAllCommits": "Push All Commits" + }, + "protectedBranch": { + "title": "Protected Branch", + "message": "The branch \"{{branchName}}\" on {{remoteName}} is protected and cannot be pushed to directly.\n\nProtected branches require changes to go through pull requests.", + "createPullRequest": "Create Pull Request" + }, + "pullConflict": { + "title": "Can't Pull with Local Changes", + "message": "Your local changes would be overwritten by the incoming changes from \"{{branchName}}\".{{fileInfo}}\n\nChoose how to proceed:", + "affectedFiles_one": " ({{count}} file affected)", + "affectedFiles_other": " ({{count}} files affected)", + "stashAndPull": "Stash & Pull", + "discardAndPull": "Discard & Pull" + }, + "pushRejected": { + "title": "Push Rejected", + "message": "Can't push to {{remoteName}}/{{branchName}}.\n\nThe remote branch contains commits that you don't have locally.{{behindInfo}}\n\n⚠️ Force push will overwrite remote changes permanently.", + "behindInfo_one": " ({{count}} commit behind)", + "behindInfo_other": " ({{count}} commits behind)", + "pullAndPush": "Pull & Push", + "forcePush": "Force Push" + }, + "rebaseConflict": { + "mergeTitle": "Merge Conflict", + "rebaseTitle": "Rebase Conflict", + "progress": " (Step {{currentStep}} of {{totalSteps}})", + "conflictingFiles_one": "\n\n{{count}} file has conflicts.", + "conflictingFiles_other": "\n\n{{count}} files have conflicts.", + "mergeMessage": "Conflicts occurred while merging \"{{targetBranch}}\".{{progressInfo}}{{fileInfo}}\n\n⚠️ Aborting will cancel the merge and restore your branch.", + "rebaseMessage": "Conflicts occurred while rebasing onto \"{{targetBranch}}\".{{progressInfo}}{{fileInfo}}\n\n⚠️ Aborting will restore your branch to its state before rebasing.", + "resolveConflicts": "Resolve Conflicts", + "abortMerge": "Abort Merge", + "abortRebase": "Abort Rebase" + }, + "remoteBranchDeleted": { + "title": "Remote Branch Deleted", + "message": "The remote tracking branch \"{{remoteName}}/{{branchName}}\" has been deleted, but your local branch still exists.\n\n⚠️ Deleting the local branch will discard any unpushed commits.", + "switchTo": "Switch to {{branchName}}", + "deleteLocalBranch": "Delete Local Branch", + "keepLocalBranch": "Keep Local Branch" + }, + "gitError": { + "titles": { + "none": "Git: Operation Completed", + "nonFastForward": "Git: Push Rejected", + "protectedBranch": "Git: Protected Branch", + "authenticationFailed": "Git: Authentication Failed", + "remoteBranchDeleted": "Git: Remote Branch Deleted", + "uncommittedChanges": "Git: Uncommitted Changes", + "networkError": "Git: Network Error", + "mergeConflicts": "Git: Merge Conflicts", + "permissionDenied": "Git: Permission Denied", + "unknown": "Git: Operation Failed" + }, + "messages": { + "none": "The Git operation completed successfully.", + "nonFastForward": "Your push was rejected because the remote branch contains newer commits.", + "protectedBranch": "The target branch is protected and cannot be updated by this operation.", + "authenticationFailed": "Failed to authenticate with the remote repository. Check your credentials and try again.", + "remoteBranchDeleted": "The remote branch appears to have been deleted or is no longer available.", + "uncommittedChanges": "Your local changes would be overwritten. Commit or stash them before retrying.", + "networkError": "Could not connect to the remote repository. Check your internet connection.", + "mergeConflicts": "The operation resulted in merge conflicts that need to be resolved.", + "permissionDenied": "Permission was denied for this repository operation. Check your repository access.", + "unknown": "The Git operation failed unexpectedly. See the log for details." + }, + "operationFailed": "{{operation}} operation failed", + "dialogMessage": "{{operation}} failed: {{baseMessage}}\n\n{{detailMessage}}", + "stashHint": "\n\nHint: \"Stash and Continue\" will stash your local changes (including untracked files), retry the operation, then ask whether you want to restore those stashed changes.", + "stashAndContinue": "Stash and Continue", + "openGitLog": "Open Git Log", + "showCommandOutput": "Show Command Output" + }, + "gitAction": { + "title": "Git", + "transportError": "Unable to reach the local Git service. Please try again after the app finishes starting, or restart ORGII if this keeps happening." + } + }, + "destructiveDialog": { + "discard": "Discard", + "cancel": "Cancel" + }, "clientOrigin": { "officialApp": "Official app", "cli": "CLI", diff --git a/src/i18n/locales/en/integrations.json b/src/i18n/locales/en/integrations.json index 563b4ed4c0..31d506d34b 100644 --- a/src/i18n/locales/en/integrations.json +++ b/src/i18n/locales/en/integrations.json @@ -479,7 +479,8 @@ "refreshModels": { "button": "Refresh models", "failed": "Refresh models failed: {{error}}" - } + }, + "authentication": "Authentication" }, "integrations": { "title": "Integrations", @@ -757,7 +758,8 @@ "modelPlaceholder": "Select a model", "groupPerUser": "Isolate group chat sessions per user", "groupPerUserDesc": "Each participant in a group chat gets their own session context." - } + }, + "emailProtocol": "Protocol" }, "tabs": { "current": "Current", @@ -1070,7 +1072,22 @@ "gitConnections": { "github": "GitHub", "methodScan": "Auto Detect", - "methodOAuth": "Sign in with GitHub" + "methodOAuth": "Sign in with GitHub", + "methodPickerDesc": "Pick how you want to authenticate to GitHub.", + "oauthDeviceDesc": "Open the verification URL and enter this code to authorize GitHub.", + "scanGhCli": "GitHub CLI (gh)", + "scanCredHelper": "Git credential helper ({{name}})", + "scanSshKey": "SSH key — {{name}}", + "scanning": "Scanning…", + "scanningDesc": "Looking for gh CLI tokens, credential helpers, and SSH keys on this machine.", + "scanEmpty": "Nothing detected", + "scanEmptyDesc": "No gh CLI tokens, credential helpers, or SSH keys were found. Pick another method above.", + "scanResults": "Detected credentials", + "scanResultsDesc": "Pick one to import. We validate tokens against GitHub before saving.", + "sshKeyPath": "SSH key path", + "sshKeyPathDesc": "Absolute path to the private key (e.g. ~/.ssh/id_ed25519). The matching public key must already be registered on GitHub.", + "scanSelectRequired": "Pick a detected credential to import.", + "sshKeyPathRequired": "SSH key path is required." }, "builtInTools": { "title": "Built-in tools", @@ -2542,7 +2559,11 @@ "queuedMessages": "Queued Messages", "fileReview": "File Review", "terminalProcesses": "Terminal Processes", - "workspaceMemory": "Workspace Memory" + "workspaceMemory": "Workspace Memory", + "sessionSimulation": "Session simulation", + "selectScenario": "Select a scenario", + "scenario": "Scenario", + "chatPreview": "Chat preview" }, "agentTools": { "enabled": "Enabled", @@ -2625,7 +2646,12 @@ "fireHistory": "Run history", "noFires": "No runs yet", "openSession": "Open session", - "openWorkItem": "Open work item" + "openWorkItem": "Open work item", + "openSessionError": "Could not open the session", + "openWorkItemError": "Could not open the Work Item", + "fireStarted": "Routine run started", + "fireAccepted": "Run {{status}}", + "fireError": "Could not start the Routine: {{detail}}" }, "localModels": { "title": "On prem", diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index efe45da924..a0104c961d 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -49,7 +49,8 @@ "projects": "Projects", "projectManager": "Project Manager", "diff": "Diff", - "terminals": "Terminals" + "terminals": "Terminals", + "teamInbox": "Team Inbox" }, "workstation": { "agentComputer": { @@ -158,9 +159,9 @@ }, "folderCounts": { "repo": "{{count}} repo", - "repo_plural": "{{count}} repos", "multiRepoWorkspace": "{{count}} Multi-Repo Workspace", - "multiRepoWorkspace_plural": "{{count}} Multi-Repo Workspaces" + "repo_other": "{{count}} repos", + "multiRepoWorkspace_other": "{{count}} Multi-Repo Workspaces" }, "bottomBar": { "settings": "Settings", @@ -196,7 +197,8 @@ "duration30m": "30 minutes", "duration1h": "1 hour", "duration2h": "2 hours", - "durationTomorrow": "Tomorrow" + "durationTomorrow": "Tomorrow", + "unknownRole": "Unknown role" }, "guide": { "trigger": "Open guide", @@ -314,7 +316,36 @@ "tabs": { "status": "My status", "about": "About me" - } + }, + "guidancePlaceholder": "How should the agent adapt its behavior when this role is active?", + "newRoleDefaultLabel": "Custom role", + "deleteRoleTitle": "Delete role?", + "deleteRoleMessage": "“{{name}}” will be removed. This cannot be undone.", + "deleteRoleOk": "Delete", + "deleteRoleCancel": "Cancel", + "stanceInteractive": "Interactive — ask me freely", + "stanceDeferAndBatch": "Defer & batch — work first, ask later", + "stanceAutonomous": "Autonomous — never wait for me", + "stanceLabel": "Behavior stance", + "stanceDesc": "How the agent treats blocking decisions while this role is active.", + "questionAutoSkipLabel": "Question auto-skip", + "questionAutoSkipDesc": "Auto-skip pending agent questions after N seconds (0 = wait for me).", + "planAutoApproveLabel": "Plan auto-approve", + "planAutoApproveDesc": "Auto-approve pending plans after N seconds (0 = wait for me).", + "modeSwitchAutoPlanLabel": "Mode switch auto-plan", + "modeSwitchAutoPlanDesc": "Auto-switch pending Plan mode suggestions when their confirmation timer expires.", + "goalMaxTurnsLabel": "Goal continuation budget", + "goalMaxTurnsDesc": "Keep working toward my last request for up to N extra turns after the agent would normally stop (0 = off).", + "pageTitle": "My Roles", + "pageDescription": "Define the role you're playing right now. The agent reads the active role's guidance and adapts its behavior accordingly — for example, asking fewer clarifying questions when you're heads-down, or batching summaries when you're away.", + "builtInTitle": "Built-in roles", + "activeBadge": "Active", + "customTitle": "Custom roles", + "customEmpty": "No custom roles yet. Add one to capture a stance the built-in three don't cover — e.g. \"Deep work\", \"Pairing\", or \"On call\".", + "roleNameLabel": "Name", + "roleNamePlaceholder": "Role name", + "guidanceLabel": "Agent guidance", + "addRole": "Add custom role" }, "launchpad": { "sections": { @@ -999,5 +1030,68 @@ "off": "Off", "memberNote": "Enabled by an admin — eligible sessions upload in the background without requiring you to open this organization." } + }, + "web": { + "title": "ORG2 Web", + "sessionsNav": "Sessions", + "loadingSession": "Loading session…", + "readOnly": { + "headerTrailing": "Cloud · Read only", + "barLabel": "Read only", + "barPlaceholder": "Cloud session is read-only" + }, + "sessionsPage": { + "loadError": "Sessions could not be loaded", + "loading": "Loading sessions…", + "empty": "No synced sessions", + "select": "Select a session", + "emptyHint": "Open ORG2 Desktop and enable Cloud sync for a session.", + "selectHint": "Choose a Cloud session from the sidebar.", + "retry": "Retry", + "organizationLoadErrorHint": "Your organizations could not be loaded. Check your connection and try again.", + "organizationRetryingHint": "The cloud service could not be reached. Retrying automatically…", + "organizationRefreshErrorHint": "Organizations could not be refreshed. Showing the last successful session list.", + "sessionRefreshErrorHint": "Some organization sessions could not be refreshed.", + "organizationSetupTitle": "Connect an organization", + "organizationSetupHint": "Create an organization for your team, or join one with an invite link or code.", + "organizationModeLabel": "Organization setup method", + "createOrganization": "Create organization", + "joinOrganization": "Join organization", + "organizationNamePlaceholder": "Team or organization name", + "invitePlaceholder": "Paste an invite link or code" + }, + "sessionPage": { + "notFound": "Session not found", + "notFoundHint": "It may no longer be shared with this account.", + "loading": "Loading session…", + "workstationLoading": "Loading session workspace…", + "workstationEmptyTitle": "No session files were uploaded", + "workstationEmptySubtitle": "This session has no readable file or edit payloads yet.", + "workstationLoadErrorFallback": "Session files could not be loaded", + "workstationLoadErrorSubtitle": "Agent Station replay remains available.", + "workstationRefreshFailedBanner": "Refresh failed. Showing files already available from session events.", + "chatTab": "Chat", + "workstationTab": "WorkStation", + "forkContext": "Fork of {{owner}}'s session", + "notesButton": "Session notes", + "notesTitle": "Session notes", + "notesEmpty": "No session notes yet." + }, + "login": { + "title": "Sign in to ORG2 Web", + "subtitle": "Review shared sessions, chat history, and replay from any browser.", + "continue": "Continue with ORG2 Cloud", + "hint": "Use the same ORG2 Cloud account as the desktop app." + }, + "authCallback": { + "failed": "Sign-in failed", + "tryAgain": "Try again", + "completing": "Completing sign-in", + "missingCredentials": "The sign-in callback is missing valid credentials.", + "missingIdentity": "The sign-in token does not contain a user identity." + }, + "sidebar": { + "signOut": "Sign out {{name}}" + } } } diff --git a/src/i18n/locales/en/projects.json b/src/i18n/locales/en/projects.json index 722ecd9161..7ea00c1416 100644 --- a/src/i18n/locales/en/projects.json +++ b/src/i18n/locales/en/projects.json @@ -359,7 +359,20 @@ "inHours": "in {{count}} hours", "membersGroup": "Members", "agentsGroup": "Agents", - "orgsGroup": "Organizations" + "orgsGroup": "Organizations", + "yes": "Yes", + "no": "No", + "datePlaceholder": "YYYY-MM-DD", + "optionsRequired": "Select properties require comma-separated options.", + "add": "Add property", + "updateFailed": "Property update failed", + "namePlaceholder": "Property name", + "type": "Property type", + "optionsPlaceholder": "Options, comma separated", + "loading": "Loading properties…", + "empty": "No custom properties yet.", + "archive": "Archive property", + "archiveNamed": "Archive {{name}}" }, "history": { "noHistory": "No history yet" @@ -442,7 +455,18 @@ "schedule": "schedule", "orchestratorConfig": "orchestrator config", "handoff": "handoff" - } + }, + "resolved": "Resolved", + "reopen": "Reopen", + "resolve": "Resolve", + "conclusion": "Conclusion", + "reply": "Reply", + "submitComment": "Submit comment", + "replyingInThread": "Replying in thread", + "cancelReply": "Cancel reply", + "openedWorkItem": "opened this work item", + "messageCount_one": "{{count}} message", + "messageCount_other": "{{count}} messages" }, "contextMenu": { "status": "Status", @@ -546,7 +570,9 @@ "emptyOverview": "No sessions yet", "emptyOverviewHint": "Start an Agent session to track execution here.", "originTitle": "Creation session", - "originStatus": "Created this item" + "originStatus": "Created this item", + "retry": "Retry", + "runsCount": "{{count}} runs" }, "outputTab": { "prSection": "Pull Request", @@ -567,7 +593,9 @@ "costSummary": "Cost Summary", "totalCost": "Total", "tokens": "tokens", - "cycles": "cycles" + "cycles": "cycles", + "prReady": "Ready to complete", + "prBlocked": "Completion is blocked" }, "viewWorkItems": "View work items", "fromRoutine": "From routine: {{name}}", @@ -590,7 +618,11 @@ "notOnDeviceTitle": "Session not on this device", "notOnDeviceHint": "This session ran on another device. Its transcript is not synced here.", "finalOutput": "Final output" - } + }, + "linkedSessions": { + "title": "Sessions" + }, + "navigationTrail": "Work item navigation" }, "settings": { "sidebarGeneral": "General", diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index fadf22a7b6..ac0982f5bb 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -422,7 +422,9 @@ "completed": "Completed session", "stillWorking": "Still working" } - } + }, + "openRoutineWorkItemError": "Could not open the Work Item", + "runsEmpty": "No routine runs yet" }, "changes": { "scope": { @@ -785,7 +787,10 @@ "readUserMessage": "Read User Message", "sessionFallback": "Session" } - } + }, + "previous": "Previous event", + "next": "Next event", + "scrub": "Replay scrub bar" }, "codeEditor": { "switchToListView": "Switch to list view", @@ -912,6 +917,7 @@ "selectedOf": "{{selected}} of {{total}} selected" }, "chat": { + "agentFallback": "Agent", "planApprovedLabel": "Implementing approved plan", "planApprovedEditedLabel": "Implementing approved plan (edited)", "restoreCheckpoint": "Restore checkpoint", @@ -1292,7 +1298,10 @@ "otherTodos": "Other todos" }, "filters": "Filters", - "openInGitHub": "Open in GitHub" + "openInGitHub": "Open in GitHub", + "updateIssueFailed": "Failed to update GitHub issue", + "assigneeUpdateNotApplied": "GitHub did not apply the assignee change", + "assigneeUpdateSuccess": "Assignees updated on GitHub" } }, "sessionViews": { @@ -1307,8 +1316,13 @@ "fileCount_one": "{{count}} file", "fileCount_other": "{{count}} files", "turnCount_one": "{{count}} turn", - "turnCount_other": "{{count}} turns" - } + "turnCount_other": "{{count}} turns", + "inferredTiming": "End inferred from the next turn" + }, + "conversationNavigator": "Conversation navigator", + "goToConversationTurn": "Go to turn {{current}} of {{total}}: {{preview}}", + "session": "Session", + "historyNoWorkspace": "No Workspace" }, "chatStatus": { "approved": "Approved", @@ -1444,6 +1458,10 @@ "runShellDone": "Ran command", "runShellFailed": "Command failed", "runCommands": "Run commands", + "runCommandRunning": "Running command", + "runCommandDone": "Command finished", + "nFiles_one": "{{count}} file", + "nFiles_other": "{{count}} files", "terminalSummary": { "command_one": "{{count}} command", "command_other": "{{count}} commands", @@ -2380,7 +2398,8 @@ "bulletList": "Bullet list", "numberedList": "Numbered list", "taskList": "Task list", - "divider": "Divider" + "divider": "Divider", + "formatting": "Text formatting" }, "regionNoticeTitle": "Possible regional limitations", "regionNoticeBodyProvider": "You are in {{location}}. Certain models may be unavailable in your region.", @@ -2437,7 +2456,8 @@ "noRepo": "Select a repo — each runner needs its own worktree", "notEnoughRunners": "Set up at least two runnable runners" } - } + }, + "mode": "Editor mode" }, "planner": { "tabs": { @@ -2783,7 +2803,8 @@ "explorations_other": "{{count}} explorations", "otherTools_one": "{{count}} other tool call", "otherTools_other": "{{count}} other tool calls" - } + }, + "triggerLabel": "Group chat" }, "orgTask": { "create": { @@ -2794,6 +2815,7 @@ "failedTitle": "Task creation failed" }, "update": { + "title": "Update {{title}}", "titleStatus": "Update task status", "titleDetail": "Update task detail", "markedAs": "Marked as {{status}}", @@ -2871,7 +2893,8 @@ "meta": { "sender": "From", "recipient": "To", - "subject": "Subject" + "subject": "Subject", + "status": "Status" }, "broadcastTitle": { "running": "Sending message to multiple agents", @@ -2948,7 +2971,11 @@ "empty": "No content", "reactDisabledTitle": "React preview runs in Simulator", "reactDisabledDescription": "Agent JavaScript is not executed inside chat. Open it in Simulator to run the sandboxed React preview.", - "viewInSimulator": "View in Simulator" + "viewInSimulator": "View in Simulator", + "openUrlTitle": "Preview not embedded", + "openUrlDescription": "External URLs are not embedded to avoid iframe memory overhead.", + "titleReact": "React Preview", + "streaming": "streaming" }, "canvasApp": { "empty": "No canvas rendered yet", @@ -3004,7 +3031,16 @@ "shareDialogError": "The Canvas link could not be created.", "shareDialogRetryShort": "Retry short link", "shareDialogRetryingShort": "Retrying…", - "retry": "Retry" + "retry": "Retry", + "reactArtifactFrame": "React canvas preview", + "recents": "Recents", + "untitled": "Untitled Canvas", + "loadingRecents": "Loading recents…", + "noRecentCanvases": "No recent canvases", + "recentsFailed": "Couldn’t load recent canvases", + "openFailed": "Couldn’t open this Canvas", + "createNew": "Create New Canvas", + "loadFailed": "Couldn’t load this Canvas" }, "domSelection": { "previewTitle": "Canvas selection preview", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d6177a29cf..c13beff0d6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -109,7 +109,13 @@ "removeJobRole": "Remove {{role}}", "description": "About you", "descriptionHelp": "A short description of your background, interests, and how you like to work", - "descriptionPlaceholder": "Tell the agent about yourself…" + "descriptionPlaceholder": "Tell the agent about yourself…", + "defaultProfile": "Default profile", + "newProfileName": "New profile", + "activeProfile": "Active profile", + "activeProfileDescription": "Choose which profile is sent to agents. Switching is manual and under your control.", + "addProfile": "Add profile", + "profileName": "Profile name" } }, "general": { @@ -731,7 +737,8 @@ "sent": "Test notification sent", "permissionWarning": "Failed to send test notification. Check permissions.", "sendFailed": "Unable to send test notification. Please check your notification permissions and try again.", - "soundFailed": "Unable to play the notification sound. Check your system volume." + "soundFailed": "Unable to play the notification sound. Check your system volume.", + "body": "This is a test notification from ORGII" }, "teamInbox": "Team Inbox", "teamInboxDesc": "Assignments, mentions, and handoffs from teammates" diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 0568d2cc5b..923fbad05c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -2696,6 +2696,16 @@ "noRepo": "Sin repo" } }, + "notifications": { + "backgroundSession": "Sesión en segundo plano", + "taskCompletedTitle": "Tarea completada", + "taskCompletedBody": "La tarea «{{name}}» se completó — lista para revisión", + "taskCompletedToast": "La tarea «{{name}}» se completó. Abre la sesión para revisar el resultado.", + "openSessionAction": "Abrir sesión", + "taskFailedTitle": "Tarea fallida", + "taskFailedBody": "La tarea «{{name}}» falló{{detail}}", + "taskCancelledToast": "La tarea «{{name}}» se canceló" + }, "globalToolbar": { "selectWorkspaceToStart": "Selecciona un Workspace para empezar", "selectRepoToStart": "Selecciona un Repo para empezar" diff --git a/src/i18n/locales/es/integrations.json b/src/i18n/locales/es/integrations.json index fead372c48..9e5db9502e 100644 --- a/src/i18n/locales/es/integrations.json +++ b/src/i18n/locales/es/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} actualizado correctamente", "refreshFailed": "{{name}} validación fallida - verifique las credenciales", "allRefreshed": "Todas las cuentas actualizadas", - "refreshError": "Error al actualizar {{name}}", + "refreshError": "Error al actualizar {{name}}: {{error}}", "localRemoved": "{{name}} eliminado", "unlistPending": "{{name}} solicitud de eliminación enviada. Se eliminará cuando finalicen las Session activas.", "listingRemoved": "{{name}} eliminado del marketplace", diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index 92ca8b0b4d..241347642b 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -129,9 +129,9 @@ }, "folderCounts": { "repo": "{{count}} Repo", - "repo_plural": "{{count}} Repos", "multiRepoWorkspace": "{{count}} Workspace multi-Repo", - "multiRepoWorkspace_plural": "{{count}} Workspaces multi-Repo" + "repo_other": "{{count}} Repos", + "multiRepoWorkspace_other": "{{count}} Workspaces multi-Repo" }, "bottomBar": { "settings": "Configuración", diff --git a/src/i18n/locales/es/sessions.json b/src/i18n/locales/es/sessions.json index 5390e5ce44..d3ec80278b 100644 --- a/src/i18n/locales/es/sessions.json +++ b/src/i18n/locales/es/sessions.json @@ -2428,7 +2428,10 @@ "memberSessions": "Sesiones de miembros", "pauseRun": "Pausar ejecución", "resumeRun": "Reanudar ejecución", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plan listo para aprobación" + } }, "agentOrgInbox": { "title": "Mensajes Agent", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 0e23d49eab..301da57507 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -732,7 +732,8 @@ "sent": "Notificación de prueba enviada", "permissionWarning": "No se pudo enviar la notificación de prueba. Comprueba los permisos.", "sendFailed": "No se puede enviar la notificación de prueba. Comprueba los permisos de notificación e inténtalo de nuevo.", - "soundFailed": "No se puede reproducir el sonido de notificación. Comprueba el volumen del sistema." + "soundFailed": "No se puede reproducir el sonido de notificación. Comprueba el volumen del sistema.", + "body": "Esta es una notificación de prueba de ORGII" } }, "editor": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index fd49f4b82e..1fce38d4b3 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -2699,6 +2699,16 @@ "noRepo": "Aucun repo" } }, + "notifications": { + "backgroundSession": "Session en arrière-plan", + "taskCompletedTitle": "Tâche terminée", + "taskCompletedBody": "La tâche « {{name}} » est terminée — prête à être révisée", + "taskCompletedToast": "La tâche « {{name}} » est terminée. Ouvrez la session pour vérifier le résultat.", + "openSessionAction": "Ouvrir la session", + "taskFailedTitle": "Échec de la tâche", + "taskFailedBody": "La tâche « {{name}} » a échoué{{detail}}", + "taskCancelledToast": "La tâche « {{name}} » a été annulée" + }, "globalToolbar": { "selectWorkspaceToStart": "Sélectionnez un Workspace pour commencer", "selectRepoToStart": "Sélectionnez un Repo pour commencer" diff --git a/src/i18n/locales/fr/integrations.json b/src/i18n/locales/fr/integrations.json index eb65ed2aa1..35299d9c8f 100644 --- a/src/i18n/locales/fr/integrations.json +++ b/src/i18n/locales/fr/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} actualisé avec succès", "refreshFailed": "{{name}} validation échouée - vérifiez les identifiants", "allRefreshed": "Tous les comptes actualisés", - "refreshError": "Échec de l'actualisation de {{name}}", + "refreshError": "Échec de l'actualisation de {{name}} : {{error}}", "localRemoved": "{{name}} supprimé", "unlistPending": "{{name}} retrait demandé. Sera supprimé à la fin des Session actives.", "listingRemoved": "{{name}} retiré du marketplace", diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index 43b3ed4ce6..57a0b26768 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -129,9 +129,9 @@ }, "folderCounts": { "repo": "{{count}} Repo", - "repo_plural": "{{count}} Repos", "multiRepoWorkspace": "{{count}} Workspace multi-Repo", - "multiRepoWorkspace_plural": "{{count}} Workspaces multi-Repo" + "repo_other": "{{count}} Repos", + "multiRepoWorkspace_other": "{{count}} Workspaces multi-Repo" }, "bottomBar": { "settings": "Paramètres", diff --git a/src/i18n/locales/fr/sessions.json b/src/i18n/locales/fr/sessions.json index 7bcbe2ddf9..72de826bee 100644 --- a/src/i18n/locales/fr/sessions.json +++ b/src/i18n/locales/fr/sessions.json @@ -2428,7 +2428,10 @@ "memberSessions": "Sessions des membres", "pauseRun": "Mettre en pause", "resumeRun": "Reprendre l'exécution", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plan prêt à être approuvé" + } }, "agentOrgInbox": { "title": "Messages Agent", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 4cfd268ddb..20feafa127 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -732,7 +732,8 @@ "sent": "Notification de test envoyée", "permissionWarning": "Échec de l'envoi de la notification de test. Vérifiez les autorisations.", "sendFailed": "Impossible d'envoyer la notification de test. Veuillez vérifier vos autorisations de notification et réessayer.", - "soundFailed": "Impossible de lire le son de notification. Vérifiez le volume du système." + "soundFailed": "Impossible de lire le son de notification. Vérifiez le volume du système.", + "body": "Ceci est une notification de test d’ORGII" } }, "editor": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a6369caaab..9195244a6f 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -2699,6 +2699,16 @@ "noRepo": "Repo なし" } }, + "notifications": { + "backgroundSession": "バックグラウンドセッション", + "taskCompletedTitle": "タスク完了", + "taskCompletedBody": "「{{name}}」が完了しました。レビューできます", + "taskCompletedToast": "「{{name}}」が完了しました。セッションを開いて結果を確認してください。", + "openSessionAction": "セッションを開く", + "taskFailedTitle": "タスク失敗", + "taskFailedBody": "「{{name}}」は失敗しました{{detail}}", + "taskCancelledToast": "「{{name}}」はキャンセルされました" + }, "globalToolbar": { "selectWorkspaceToStart": "Workspace を選択して開始", "selectRepoToStart": "Repo を選択して開始" diff --git a/src/i18n/locales/ja/integrations.json b/src/i18n/locales/ja/integrations.json index 94ba5aedb8..8fe4d3f9a6 100644 --- a/src/i18n/locales/ja/integrations.json +++ b/src/i18n/locales/ja/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} の更新が完了しました", "refreshFailed": "{{name}} の検証に失敗しました - 認証情報を確認してください", "allRefreshed": "すべてのアカウントを更新しました", - "refreshError": "{{name}} の更新に失敗しました", + "refreshError": "{{name}} の更新に失敗しました:{{error}}", "localRemoved": "{{name}} を削除しました", "unlistPending": "{{name}} のリスト解除をリクエストしました。アクティブなSession終了後に削除されます。", "listingRemoved": "{{name}} をマーケットプレイスから削除しました", diff --git a/src/i18n/locales/ja/sessions.json b/src/i18n/locales/ja/sessions.json index 82cb80a352..df924078e6 100644 --- a/src/i18n/locales/ja/sessions.json +++ b/src/i18n/locales/ja/sessions.json @@ -2145,7 +2145,7 @@ "searchModels": "モデルを検索...", "newItem": "新しいアイテム", "solveWorkItem": "作業項目を解決", - "launchpadQuestion": "", + "launchpadQuestion": "選択した", "launchpadQuestionSuffix": "で何を作りますか?", "manualLaunchpadQuestion": "何を作りますか?", "start": "開始", @@ -2427,7 +2427,10 @@ "memberSessions": "メンバーセッション", "pauseRun": "実行を一時停止", "resumeRun": "実行を再開", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "プランの承認待ち" + } }, "agentOrgInbox": { "title": "Agent メッセージ", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 4289191f00..ff81c7c55a 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -732,7 +732,8 @@ "sent": "テスト通知を送信しました", "permissionWarning": "テスト通知の送信に失敗しました。権限を確認してください。", "sendFailed": "テスト通知を送信できません。通知の権限を確認してから再試行してください。", - "soundFailed": "通知音を再生できません。システムの音量を確認してください。" + "soundFailed": "通知音を再生できません。システムの音量を確認してください。", + "body": "これは ORGII からのテスト通知です" } }, "editor": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0ca5744172..feb3221608 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -2697,6 +2697,16 @@ "noRepo": "Repo 없음" } }, + "notifications": { + "backgroundSession": "백그라운드 세션", + "taskCompletedTitle": "작업 완료", + "taskCompletedBody": "“{{name}}” 작업이 완료되어 검토할 수 있습니다", + "taskCompletedToast": "“{{name}}” 작업이 완료되었습니다. 세션을 열어 결과를 검토하세요.", + "openSessionAction": "세션 열기", + "taskFailedTitle": "작업 실패", + "taskFailedBody": "“{{name}}” 작업에 실패했습니다{{detail}}", + "taskCancelledToast": "“{{name}}” 작업이 취소되었습니다" + }, "globalToolbar": { "selectWorkspaceToStart": "Workspace를 선택하여 시작", "selectRepoToStart": "Repo를 선택하여 시작" diff --git a/src/i18n/locales/ko/integrations.json b/src/i18n/locales/ko/integrations.json index 61580d7e51..fb5268a307 100644 --- a/src/i18n/locales/ko/integrations.json +++ b/src/i18n/locales/ko/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} 새로고침 완료", "refreshFailed": "{{name}} 검증 실패 - 자격 증명을 확인하세요", "allRefreshed": "모든 계정 새로고침 완료", - "refreshError": "{{name}} 새로고침 실패", + "refreshError": "{{name}} 새로고침 실패: {{error}}", "localRemoved": "{{name}} 제거됨", "unlistPending": "{{name}} 목록 해제 요청됨. 활성 Session 종료 후 제거됩니다.", "listingRemoved": "{{name}} 마켓플레이스에서 제거됨", diff --git a/src/i18n/locales/ko/sessions.json b/src/i18n/locales/ko/sessions.json index 70bf1c7424..db26c88d7c 100644 --- a/src/i18n/locales/ko/sessions.json +++ b/src/i18n/locales/ko/sessions.json @@ -2145,7 +2145,7 @@ "searchModels": "모델 검색...", "newItem": "새 항목", "solveWorkItem": "작업 항목 해결", - "launchpadQuestion": "", + "launchpadQuestion": "선택한", "launchpadQuestionSuffix": "와 함께 무엇을 만들고 싶으신가요?", "manualLaunchpadQuestion": "무엇을 만들고 싶으신가요?", "start": "시작", @@ -2428,7 +2428,10 @@ "memberSessions": "Member 세션", "pauseRun": "실행 일시 중지", "resumeRun": "실행 재개", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "계획 승인 대기 중" + } }, "agentOrgInbox": { "title": "Agent 메시지", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 84795051e6..09884cb3a5 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -732,7 +732,8 @@ "sent": "테스트 알림 발송됨", "permissionWarning": "테스트 알림 전송 실패. 권한을 확인하세요.", "sendFailed": "테스트 알림을 보낼 수 없습니다. 알림 권한을 확인하고 다시 시도하세요.", - "soundFailed": "알림 소리를 재생할 수 없습니다. 시스템 볼륨을 확인하세요." + "soundFailed": "알림 소리를 재생할 수 없습니다. 시스템 볼륨을 확인하세요.", + "body": "ORGII에서 보낸 테스트 알림입니다" } }, "editor": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 85da9f7c17..650923b749 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -2708,6 +2708,16 @@ "noRepo": "Brak repo" } }, + "notifications": { + "backgroundSession": "Sesja w tle", + "taskCompletedTitle": "Zadanie ukończone", + "taskCompletedBody": "„{{name}}” ukończono — gotowe do sprawdzenia", + "taskCompletedToast": "„{{name}}” ukończono. Otwórz sesję, aby sprawdzić wynik.", + "openSessionAction": "Otwórz sesję", + "taskFailedTitle": "Zadanie nie powiodło się", + "taskFailedBody": "„{{name}}” nie powiodło się{{detail}}", + "taskCancelledToast": "„{{name}}” anulowano" + }, "globalToolbar": { "selectWorkspaceToStart": "Wybierz Workspace, aby rozpocząć", "selectRepoToStart": "Wybierz Repo, aby rozpocząć" diff --git a/src/i18n/locales/pl/integrations.json b/src/i18n/locales/pl/integrations.json index 588f4ba4e7..fb96d9f37c 100644 --- a/src/i18n/locales/pl/integrations.json +++ b/src/i18n/locales/pl/integrations.json @@ -33,7 +33,7 @@ "refreshed": "{{name}} odświeżono pomyślnie", "refreshFailed": "Walidacja {{name}} nie powiodła się — sprawdź swoje klucze", "allRefreshed": "Wszystkie konta odświeżone", - "refreshError": "Nie udało się odświeżyć {{name}}", + "refreshError": "Nie udało się odświeżyć {{name}}: {{error}}", "localRemoved": "{{name}} usunięto", "unlistPending": "Zgłoszono wycofanie {{name}} z listy. Zostanie usunięte po zakończeniu aktywnych sesji.", "listingRemoved": "{{name}} usunięto z marketplace", diff --git a/src/i18n/locales/pl/sessions.json b/src/i18n/locales/pl/sessions.json index 91dd82e891..850b042d51 100644 --- a/src/i18n/locales/pl/sessions.json +++ b/src/i18n/locales/pl/sessions.json @@ -2481,7 +2481,10 @@ "memberSessions": "Sesje członków", "pauseRun": "Wstrzymaj wykonanie", "resumeRun": "Wznów wykonanie", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plan gotowy do zatwierdzenia" + } }, "agentOrgInbox": { "title": "Wiadomości Agent", diff --git a/src/i18n/locales/pl/settings.json b/src/i18n/locales/pl/settings.json index 248210f79f..adf9cdc993 100644 --- a/src/i18n/locales/pl/settings.json +++ b/src/i18n/locales/pl/settings.json @@ -732,7 +732,8 @@ "sent": "Wysłano testowe powiadomienie", "permissionWarning": "Nie udało się wysłać testowego powiadomienia. Sprawdź uprawnienia.", "sendFailed": "Nie można wysłać testowego powiadomienia. Sprawdź uprawnienia do powiadomień i spróbuj ponownie.", - "soundFailed": "Nie można odtworzyć dźwięku powiadomienia. Sprawdź głośność systemu." + "soundFailed": "Nie można odtworzyć dźwięku powiadomienia. Sprawdź głośność systemu.", + "body": "To jest powiadomienie testowe od ORGII" } }, "editor": { diff --git a/src/i18n/locales/pt/common.json b/src/i18n/locales/pt/common.json index c0b83a3105..66c578ca9f 100644 --- a/src/i18n/locales/pt/common.json +++ b/src/i18n/locales/pt/common.json @@ -2696,6 +2696,16 @@ "noRepo": "Sem repo" } }, + "notifications": { + "backgroundSession": "Sessão em segundo plano", + "taskCompletedTitle": "Tarefa concluída", + "taskCompletedBody": "“{{name}}” foi concluída — pronta para revisão", + "taskCompletedToast": "“{{name}}” foi concluída. Abra a sessão para revisar o resultado.", + "openSessionAction": "Abrir sessão", + "taskFailedTitle": "Falha na tarefa", + "taskFailedBody": "“{{name}}” falhou{{detail}}", + "taskCancelledToast": "“{{name}}” foi cancelada" + }, "globalToolbar": { "selectWorkspaceToStart": "Selecione um Workspace para começar", "selectRepoToStart": "Selecione um Repo para começar" diff --git a/src/i18n/locales/pt/integrations.json b/src/i18n/locales/pt/integrations.json index ca751f2638..9cd81717d8 100644 --- a/src/i18n/locales/pt/integrations.json +++ b/src/i18n/locales/pt/integrations.json @@ -33,7 +33,7 @@ "refreshed": "{{name}} atualizado com sucesso", "refreshFailed": "Falha na validação de {{name}} — verifique suas chaves", "allRefreshed": "Todas as contas atualizadas", - "refreshError": "Falha ao atualizar {{name}}", + "refreshError": "Falha ao atualizar {{name}}: {{error}}", "localRemoved": "{{name}} removido", "unlistPending": "Remoção do anúncio {{name}} solicitada. Será removido quando as sessões ativas terminarem.", "listingRemoved": "{{name}} removido do marketplace", diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index bd10ea946e..3448204e88 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -129,9 +129,9 @@ }, "folderCounts": { "repo": "{{count}} Repo", - "repo_plural": "{{count}} Repos", "multiRepoWorkspace": "{{count}} Workspace multi-Repo", - "multiRepoWorkspace_plural": "{{count}} Workspaces multi-Repo" + "repo_other": "{{count}} Repos", + "multiRepoWorkspace_other": "{{count}} Workspaces multi-Repo" }, "bottomBar": { "settings": "Configurações", diff --git a/src/i18n/locales/pt/sessions.json b/src/i18n/locales/pt/sessions.json index 6e40ae2376..4c0972a701 100644 --- a/src/i18n/locales/pt/sessions.json +++ b/src/i18n/locales/pt/sessions.json @@ -2443,7 +2443,10 @@ "memberSessions": "Sessões de membros", "pauseRun": "Pausar execução", "resumeRun": "Retomar execução", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plano pronto para aprovação" + } }, "agentOrgInbox": { "title": "Mensagens Agent", diff --git a/src/i18n/locales/pt/settings.json b/src/i18n/locales/pt/settings.json index ce54a4e08c..4643d641da 100644 --- a/src/i18n/locales/pt/settings.json +++ b/src/i18n/locales/pt/settings.json @@ -732,7 +732,8 @@ "sent": "Notificação de teste enviada", "permissionWarning": "Falha ao enviar notificação de teste. Verifique as permissões.", "sendFailed": "Não foi possível enviar a notificação de teste. Verifique suas permissões de notificação e tente novamente.", - "soundFailed": "Não foi possível reproduzir o som de notificação. Verifique o volume do sistema." + "soundFailed": "Não foi possível reproduzir o som de notificação. Verifique o volume do sistema.", + "body": "Esta é uma notificação de teste do ORGII" } }, "editor": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 90e95e35b2..9359e7b821 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -2708,6 +2708,16 @@ "noRepo": "Нет repo" } }, + "notifications": { + "backgroundSession": "Фоновая сессия", + "taskCompletedTitle": "Задача завершена", + "taskCompletedBody": "«{{name}}» завершена — можно проверить", + "taskCompletedToast": "«{{name}}» завершена. Откройте сессию, чтобы проверить результат.", + "openSessionAction": "Открыть сессию", + "taskFailedTitle": "Ошибка задачи", + "taskFailedBody": "«{{name}}» завершилась с ошибкой{{detail}}", + "taskCancelledToast": "«{{name}}» отменена" + }, "globalToolbar": { "selectWorkspaceToStart": "Выберите Workspace, чтобы начать", "selectRepoToStart": "Выберите Repo, чтобы начать" diff --git a/src/i18n/locales/ru/integrations.json b/src/i18n/locales/ru/integrations.json index 4fda138897..60174130f8 100644 --- a/src/i18n/locales/ru/integrations.json +++ b/src/i18n/locales/ru/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} успешно обновлён", "refreshFailed": "{{name}} проверка не пройдена - проверьте учётные данные", "allRefreshed": "Все аккаунты обновлены", - "refreshError": "Не удалось обновить {{name}}", + "refreshError": "Не удалось обновить {{name}}: {{error}}", "localRemoved": "{{name}} удалён", "unlistPending": "{{name}} запрос на удаление отправлен. Будет удалено после завершения активных Session.", "listingRemoved": "{{name}} удалён из маркетплейса", diff --git a/src/i18n/locales/ru/sessions.json b/src/i18n/locales/ru/sessions.json index 3f93c1474d..cfa431c264 100644 --- a/src/i18n/locales/ru/sessions.json +++ b/src/i18n/locales/ru/sessions.json @@ -2472,7 +2472,10 @@ "memberSessions": "Сессии участников", "pauseRun": "Приостановить выполнение", "resumeRun": "Возобновить выполнение", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "План готов к утверждению" + } }, "agentOrgInbox": { "title": "Сообщения Agent", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 83560ea083..aa1343e76d 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -732,7 +732,8 @@ "sent": "Тестовое уведомление отправлено", "permissionWarning": "Не удалось отправить тестовое уведомление. Проверьте разрешения.", "sendFailed": "Не удалось отправить тестовое уведомление. Проверьте разрешения на уведомления и повторите попытку.", - "soundFailed": "Не удалось воспроизвести звук уведомления. Проверьте системную громкость." + "soundFailed": "Не удалось воспроизвести звук уведомления. Проверьте системную громкость.", + "body": "Это тестовое уведомление от ORGII" } }, "editor": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 0face6d7cd..275af55af3 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -2701,6 +2701,16 @@ "noRepo": "Repo yok" } }, + "notifications": { + "backgroundSession": "Arka plan oturumu", + "taskCompletedTitle": "Görev tamamlandı", + "taskCompletedBody": "“{{name}}” tamamlandı — incelemeye hazır", + "taskCompletedToast": "“{{name}}” tamamlandı. Sonucu incelemek için oturumu açın.", + "openSessionAction": "Oturumu aç", + "taskFailedTitle": "Görev başarısız oldu", + "taskFailedBody": "“{{name}}” başarısız oldu{{detail}}", + "taskCancelledToast": "“{{name}}” iptal edildi" + }, "globalToolbar": { "selectWorkspaceToStart": "Başlamak için bir Workspace seçin", "selectRepoToStart": "Başlamak için bir Repo seçin" diff --git a/src/i18n/locales/tr/integrations.json b/src/i18n/locales/tr/integrations.json index 3da97c99ee..05c2f8447d 100644 --- a/src/i18n/locales/tr/integrations.json +++ b/src/i18n/locales/tr/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} başarıyla yenilendi", "refreshFailed": "{{name}} doğrulama başarısız - kimlik bilgilerini kontrol edin", "allRefreshed": "Tüm hesaplar yenilendi", - "refreshError": "{{name}} yenilenemedi", + "refreshError": "{{name}} yenilenemedi: {{error}}", "localRemoved": "{{name}} kaldırıldı", "unlistPending": "{{name}} listeden çıkarma istendi. Aktif Session sona erdiğinde kaldırılacak.", "listingRemoved": "{{name}} pazar yerinden kaldırıldı", diff --git a/src/i18n/locales/tr/sessions.json b/src/i18n/locales/tr/sessions.json index b0838be90a..193956ef33 100644 --- a/src/i18n/locales/tr/sessions.json +++ b/src/i18n/locales/tr/sessions.json @@ -2146,7 +2146,7 @@ "searchModels": "Model ara...", "newItem": "Yeni öğe", "solveWorkItem": "İş öğesini çöz", - "launchpadQuestion": "", + "launchpadQuestion": "Seçtiğiniz", "launchpadQuestionSuffix": "ile ne oluşturmak istersiniz?", "manualLaunchpadQuestion": "Ne oluşturmak istersiniz?", "start": "Başlat", @@ -2429,7 +2429,10 @@ "memberSessions": "Üye oturumları", "pauseRun": "Çalıştırmayı duraklat", "resumeRun": "Çalıştırmayı devam ettir", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Plan onaya hazır" + } }, "agentOrgInbox": { "title": "Agent mesajları", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index d471682241..6f5341f0c8 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -732,7 +732,8 @@ "sent": "Test bildirimi gönderildi", "permissionWarning": "Test bildirimi gönderilemedi. İzinleri kontrol edin.", "sendFailed": "Test bildirimi gönderilemiyor. Bildirim izinlerinizi kontrol edip yeniden deneyin.", - "soundFailed": "Bildirim sesi çalınamıyor. Sistem ses düzeyini kontrol edin." + "soundFailed": "Bildirim sesi çalınamıyor. Sistem ses düzeyini kontrol edin.", + "body": "Bu, ORGII tarafından gönderilen bir test bildirimidir" } }, "editor": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 8d5856c74d..d6940e398f 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -2694,6 +2694,16 @@ "noRepo": "Không có repo" } }, + "notifications": { + "backgroundSession": "Phiên chạy nền", + "taskCompletedTitle": "Tác vụ đã hoàn tất", + "taskCompletedBody": "“{{name}}” đã hoàn tất — sẵn sàng để xem xét", + "taskCompletedToast": "“{{name}}” đã hoàn tất. Mở phiên để xem xét kết quả.", + "openSessionAction": "Mở phiên", + "taskFailedTitle": "Tác vụ thất bại", + "taskFailedBody": "“{{name}}” đã thất bại{{detail}}", + "taskCancelledToast": "“{{name}}” đã bị hủy" + }, "globalToolbar": { "selectWorkspaceToStart": "Chọn một Workspace để bắt đầu", "selectRepoToStart": "Chọn một Repo để bắt đầu" diff --git a/src/i18n/locales/vi/integrations.json b/src/i18n/locales/vi/integrations.json index a98a5c7e88..c62faaf99e 100644 --- a/src/i18n/locales/vi/integrations.json +++ b/src/i18n/locales/vi/integrations.json @@ -32,7 +32,7 @@ "refreshed": "{{name}} đã làm mới thành công", "refreshFailed": "{{name}} xác thực thất bại - kiểm tra thông tin đăng nhập", "allRefreshed": "Tất cả tài khoản đã được làm mới", - "refreshError": "Không thể làm mới {{name}}", + "refreshError": "Không thể làm mới {{name}}: {{error}}", "localRemoved": "{{name}} đã xóa", "unlistPending": "{{name}} yêu cầu hủy niêm yết. Sẽ được xóa khi các Session hoạt động kết thúc.", "listingRemoved": "{{name}} đã xóa khỏi marketplace", diff --git a/src/i18n/locales/vi/sessions.json b/src/i18n/locales/vi/sessions.json index 5848ba3a17..e8b2a92874 100644 --- a/src/i18n/locales/vi/sessions.json +++ b/src/i18n/locales/vi/sessions.json @@ -2425,7 +2425,10 @@ "memberSessions": "Phiên thành viên", "pauseRun": "Tạm dừng thực thi", "resumeRun": "Tiếp tục thực thi", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "Kế hoạch đã sẵn sàng để phê duyệt" + } }, "agentOrgInbox": { "title": "Tin nhắn Agent", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 2702d06593..481264dd05 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -732,7 +732,8 @@ "sent": "Đã gửi thông báo thử nghiệm", "permissionWarning": "Không gửi được thông báo thử nghiệm. Kiểm tra quyền truy cập.", "sendFailed": "Không thể gửi thông báo thử nghiệm. Vui lòng kiểm tra quyền thông báo và thử lại.", - "soundFailed": "Không thể phát âm thanh thông báo. Hãy kiểm tra âm lượng hệ thống." + "soundFailed": "Không thể phát âm thanh thông báo. Hãy kiểm tra âm lượng hệ thống.", + "body": "Đây là thông báo thử nghiệm từ ORGII" } }, "editor": { diff --git a/src/i18n/locales/zh-Hant/common.json b/src/i18n/locales/zh-Hant/common.json index de559972a1..68b0c47608 100644 --- a/src/i18n/locales/zh-Hant/common.json +++ b/src/i18n/locales/zh-Hant/common.json @@ -2775,6 +2775,148 @@ }, "selectRow": "選擇 {{id}}" }, + "notifications": { + "backgroundSession": "背景工作階段", + "taskCompletedTitle": "任務已完成", + "taskCompletedBody": "「{{name}}」已完成,可以檢視結果", + "taskCompletedToast": "「{{name}}」已完成。請開啟工作階段以檢視結果。", + "openSessionAction": "開啟工作階段", + "taskFailedTitle": "任務失敗", + "taskFailedBody": "「{{name}}」失敗{{detail}}", + "taskCancelledToast": "「{{name}}」已取消" + }, + "gitDialogs": { + "common": { + "cancel": "取消", + "ok": "確定", + "currentBranch": "目前分支", + "gitSaid": "Git 提示:{{message}}" + }, + "operations": { + "push": "推送", + "pull": "拉取", + "fetch": "取得遠端更新", + "checkout": "切換分支", + "sync": "同步", + "merge": "合併", + "rebase": "變基", + "commit": "提交", + "clone": "複製儲存庫", + "other": "{{operation}}" + }, + "checkoutBlocked": { + "worktreeTitle": "該分支已在另一個工作樹中開啟", + "mergeTitle": "請先完成合併", + "rebaseTitle": "請先完成變基", + "cherryPickTitle": "請先完成揀選提交", + "branchNotFoundTitle": "找不到分支", + "cannotSwitchTitle": "無法切換分支", + "worktreeMessage": "無法切換到「{{branchName}}」,因為 Git 顯示該分支已在另一個工作樹中簽出。\n\n請從原始碼控制開啟該工作樹,或為此工作區選擇其他分支。", + "mergeMessage": "合併正在進行,無法切換到「{{branchName}}」。\n\n請先解決衝突,然後繼續或中止合併,再切換分支。", + "rebaseMessage": "變基正在進行,無法切換到「{{branchName}}」。\n\n請先繼續或中止變基,再切換分支。", + "cherryPickMessage": "揀選提交正在進行,無法切換到「{{branchName}}」。\n\n請先繼續或中止揀選提交,再切換分支。", + "branchNotFoundMessage": "找不到分支「{{branchName}}」。請取得遠端分支後再試一次。", + "cannotSwitchMessage": "無法切換到「{{branchName}}」。" + }, + "checkoutConflict": { + "title": "切換分支衝突", + "message": "切換到「{{branchName}}」會覆寫本機變更。", + "stashAndCheckout": "暫存變更並切換", + "discardAndCheckout": "捨棄變更並切換" + }, + "detachedHead": { + "title": "分離 HEAD 狀態", + "message": "目前處於提交 {{shortHash}} 的分離 HEAD 狀態。\n\n你目前不在任何分支上。若未先建立新分支就切換到其他分支,在此所做的提交可能會遺失。\n\n⚠️ 分離 HEAD 狀態下的提交可能會被 Git 垃圾回收。", + "createBranch": "建立分支", + "continueWithoutBranch": "不建立分支並繼續" + }, + "largePush": { + "title": "偵測到大量推送", + "message_one": "即將向 {{remoteName}}/{{branchName}} 推送 {{count}} 個提交。\n\n此次推送數量較多,確定要繼續嗎?\n\n💡 建議將大型變更拆分成較小且更聚焦的提交。", + "message_other": "即將向 {{remoteName}}/{{branchName}} 推送 {{count}} 個提交。\n\n此次推送數量較多,確定要繼續嗎?\n\n💡 建議將大型變更拆分成較小且更聚焦的提交。", + "pushAllCommits": "推送所有提交" + }, + "protectedBranch": { + "title": "受保護分支", + "message": "{{remoteName}} 上的分支「{{branchName}}」受到保護,無法直接推送。\n\n對受保護分支的變更必須透過 Pull Request 提交。", + "createPullRequest": "建立 Pull Request" + }, + "pullConflict": { + "title": "存在本機變更,無法拉取", + "message": "來自「{{branchName}}」的新變更會覆寫本機變更。{{fileInfo}}\n\n請選擇處理方式:", + "affectedFiles_one": "(影響 {{count}} 個檔案)", + "affectedFiles_other": "(影響 {{count}} 個檔案)", + "stashAndPull": "暫存變更並拉取", + "discardAndPull": "捨棄變更並拉取" + }, + "pushRejected": { + "title": "推送遭拒", + "message": "無法推送到 {{remoteName}}/{{branchName}}。\n\n遠端分支包含本機尚未擁有的提交。{{behindInfo}}\n\n⚠️ 強制推送會永久覆寫遠端變更。", + "behindInfo_one": "(落後 {{count}} 個提交)", + "behindInfo_other": "(落後 {{count}} 個提交)", + "pullAndPush": "拉取後推送", + "forcePush": "強制推送" + }, + "rebaseConflict": { + "mergeTitle": "合併衝突", + "rebaseTitle": "變基衝突", + "progress": "(第 {{currentStep}} / {{totalSteps}} 步)", + "conflictingFiles_one": "\n\n{{count}} 個檔案有衝突。", + "conflictingFiles_other": "\n\n{{count}} 個檔案有衝突。", + "mergeMessage": "合併「{{targetBranch}}」時發生衝突。{{progressInfo}}{{fileInfo}}\n\n⚠️ 中止操作將取消合併並還原目前分支。", + "rebaseMessage": "變基到「{{targetBranch}}」時發生衝突。{{progressInfo}}{{fileInfo}}\n\n⚠️ 中止操作會將目前分支還原到變基前的狀態。", + "resolveConflicts": "解決衝突", + "abortMerge": "中止合併", + "abortRebase": "中止變基" + }, + "remoteBranchDeleted": { + "title": "遠端分支已刪除", + "message": "遠端追蹤分支「{{remoteName}}/{{branchName}}」已刪除,但本機分支仍然存在。\n\n⚠️ 刪除本機分支會捨棄所有尚未推送的提交。", + "switchTo": "切換到 {{branchName}}", + "deleteLocalBranch": "刪除本機分支", + "keepLocalBranch": "保留本機分支" + }, + "gitError": { + "titles": { + "none": "Git:操作已完成", + "nonFastForward": "Git:推送遭拒", + "protectedBranch": "Git:受保護分支", + "authenticationFailed": "Git:驗證失敗", + "remoteBranchDeleted": "Git:遠端分支已刪除", + "uncommittedChanges": "Git:存在未提交的變更", + "networkError": "Git:網路錯誤", + "mergeConflicts": "Git:合併衝突", + "permissionDenied": "Git:權限不足", + "unknown": "Git:操作失敗" + }, + "messages": { + "none": "Git 操作已成功完成。", + "nonFastForward": "遠端分支包含較新的提交,因此推送遭拒。", + "protectedBranch": "目標分支受到保護,無法透過此操作更新。", + "authenticationFailed": "無法通過遠端儲存庫的驗證。請檢查憑證後再試一次。", + "remoteBranchDeleted": "遠端分支似乎已刪除或不再可用。", + "uncommittedChanges": "本機變更將被覆寫。請先提交或暫存這些變更再試一次。", + "networkError": "無法連線到遠端儲存庫。請檢查網路連線。", + "mergeConflicts": "此操作產生了需要解決的合併衝突。", + "permissionDenied": "此儲存庫操作遭拒。請檢查你的儲存庫存取權限。", + "unknown": "Git 操作意外失敗。請查看記錄以瞭解詳情。" + }, + "operationFailed": "{{operation}}操作失敗", + "dialogMessage": "{{operation}}失敗:{{baseMessage}}\n\n{{detailMessage}}", + "stashHint": "\n\n提示:「暫存變更並繼續」會暫存本機變更(包括未追蹤檔案),重試該操作,然後詢問是否還原這些變更。", + "stashAndContinue": "暫存變更並繼續", + "openGitLog": "開啟 Git 記錄", + "showCommandOutput": "顯示命令輸出" + }, + "gitAction": { + "title": "Git", + "transportError": "無法連線到本機 Git 服務。請等待應用程式啟動完成後再試一次;若問題持續發生,請重新啟動 ORGII。" + } + }, + "destructiveDialog": { + "discard": "捨棄", + "cancel": "取消" + }, "clientOrigin": { "officialApp": "官方應用", "cli": "命令列", diff --git a/src/i18n/locales/zh-Hant/sessions.json b/src/i18n/locales/zh-Hant/sessions.json index 30840a52ef..168c425069 100644 --- a/src/i18n/locales/zh-Hant/sessions.json +++ b/src/i18n/locales/zh-Hant/sessions.json @@ -2443,7 +2443,10 @@ "memberSessions": "成員會話", "pauseRun": "暫停執行", "resumeRun": "恢復執行", - "viewCoordinatorHistory": "View coordinator chat history" + "viewCoordinatorHistory": "View coordinator chat history", + "planApproval": { + "title": "計畫等待核准" + } }, "agentOrgInbox": { "title": "Agent 訊息", diff --git a/src/i18n/locales/zh-Hant/settings.json b/src/i18n/locales/zh-Hant/settings.json index 08769e2a05..45e2c9f709 100644 --- a/src/i18n/locales/zh-Hant/settings.json +++ b/src/i18n/locales/zh-Hant/settings.json @@ -732,7 +732,8 @@ "sent": "測試通知已發送", "permissionWarning": "測試通知發送失敗,請檢查權限。", "sendFailed": "無法發送測試通知。請檢查通知權限後重試。", - "soundFailed": "無法播放通知音效,請檢查系統音量。" + "soundFailed": "無法播放通知音效,請檢查系統音量。", + "body": "這是一則來自 ORGII 的測試通知" } }, "editor": { diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index e0340301aa..438825ea50 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -1781,7 +1781,6 @@ "switchNo": "否,仅本次会话使用", "switchWorkspace": "切换工作区", "switchBranch": "切换分支", - "selectWorktreeSource": "选择 Worktree 分支或拉取请求", "switchLocation": "切换运行位置", "sessionWorkspace": "切换 Session 工作区", "locationAria": "选择运行位置", @@ -1794,7 +1793,8 @@ "label": "Documents", "description": "Documents 文件夹" } - } + }, + "selectWorktreeSource": "选择 Worktree 分支或拉取请求" }, "spotlightFooter": { "navigate": "导航", @@ -2840,6 +2840,138 @@ "preparingDownload": "正在准备下载…", "downloadingAndInstalling": "正在下载并安装更新 (v{{version}})…" }, + "gitDialogs": { + "common": { + "cancel": "取消", + "ok": "确定", + "currentBranch": "当前分支", + "gitSaid": "Git 提示:{{message}}" + }, + "operations": { + "push": "推送", + "pull": "拉取", + "fetch": "获取远程更新", + "checkout": "切换分支", + "sync": "同步", + "merge": "合并", + "rebase": "变基", + "commit": "提交", + "clone": "克隆", + "other": "{{operation}}" + }, + "checkoutBlocked": { + "worktreeTitle": "该分支已在另一个工作树中打开", + "mergeTitle": "请先完成合并", + "rebaseTitle": "请先完成变基", + "cherryPickTitle": "请先完成拣选提交", + "branchNotFoundTitle": "未找到分支", + "cannotSwitchTitle": "无法切换分支", + "worktreeMessage": "无法切换到“{{branchName}}”,因为 Git 显示该分支已在另一个工作树中检出。\n\n请从源代码管理中打开那个工作树,或为此工作区选择其他分支。", + "mergeMessage": "合并正在进行,无法切换到“{{branchName}}”。\n\n请先解决冲突,然后继续或中止合并,再切换分支。", + "rebaseMessage": "变基正在进行,无法切换到“{{branchName}}”。\n\n请先继续或中止变基,再切换分支。", + "cherryPickMessage": "拣选提交正在进行,无法切换到“{{branchName}}”。\n\n请先继续或中止拣选提交,再切换分支。", + "branchNotFoundMessage": "未找到分支“{{branchName}}”。请获取远程分支后重试。", + "cannotSwitchMessage": "无法切换到“{{branchName}}”。" + }, + "checkoutConflict": { + "title": "切换分支冲突", + "message": "切换到“{{branchName}}”会覆盖本地更改。", + "stashAndCheckout": "暂存更改并切换", + "discardAndCheckout": "丢弃更改并切换" + }, + "detachedHead": { + "title": "分离 HEAD 状态", + "message": "当前处于提交 {{shortHash}} 的分离 HEAD 状态。\n\n你目前不在任何分支上。如果未先创建新分支就切换到其他分支,在此所做的提交可能会丢失。\n\n⚠️ 分离 HEAD 状态下的提交可能会被 Git 垃圾回收。", + "createBranch": "创建分支", + "continueWithoutBranch": "不创建分支并继续" + }, + "largePush": { + "title": "检测到大量推送", + "message_one": "即将向 {{remoteName}}/{{branchName}} 推送 {{count}} 个提交。\n\n此次推送数量较多,确定要继续吗?\n\n💡 建议将大型更改拆分为更小、更聚焦的提交。", + "message_other": "即将向 {{remoteName}}/{{branchName}} 推送 {{count}} 个提交。\n\n此次推送数量较多,确定要继续吗?\n\n💡 建议将大型更改拆分为更小、更聚焦的提交。", + "pushAllCommits": "推送所有提交" + }, + "protectedBranch": { + "title": "受保护分支", + "message": "{{remoteName}} 上的分支“{{branchName}}”受保护,无法直接推送。\n\n对受保护分支的更改必须通过 Pull Request 提交。", + "createPullRequest": "创建 Pull Request" + }, + "pullConflict": { + "title": "存在本地更改,无法拉取", + "message": "来自“{{branchName}}”的新更改会覆盖本地更改。{{fileInfo}}\n\n请选择处理方式:", + "affectedFiles_one": "(影响 {{count}} 个文件)", + "affectedFiles_other": "(影响 {{count}} 个文件)", + "stashAndPull": "暂存更改并拉取", + "discardAndPull": "丢弃更改并拉取" + }, + "pushRejected": { + "title": "推送被拒绝", + "message": "无法推送到 {{remoteName}}/{{branchName}}。\n\n远程分支包含本地尚未拥有的提交。{{behindInfo}}\n\n⚠️ 强制推送会永久覆盖远程更改。", + "behindInfo_one": "(落后 {{count}} 个提交)", + "behindInfo_other": "(落后 {{count}} 个提交)", + "pullAndPush": "拉取后推送", + "forcePush": "强制推送" + }, + "rebaseConflict": { + "mergeTitle": "合并冲突", + "rebaseTitle": "变基冲突", + "progress": "(第 {{currentStep}} / {{totalSteps}} 步)", + "conflictingFiles_one": "\n\n{{count}} 个文件存在冲突。", + "conflictingFiles_other": "\n\n{{count}} 个文件存在冲突。", + "mergeMessage": "合并“{{targetBranch}}”时发生冲突。{{progressInfo}}{{fileInfo}}\n\n⚠️ 中止操作将取消合并并恢复当前分支。", + "rebaseMessage": "变基到“{{targetBranch}}”时发生冲突。{{progressInfo}}{{fileInfo}}\n\n⚠️ 中止操作会将当前分支恢复到变基前的状态。", + "resolveConflicts": "解决冲突", + "abortMerge": "中止合并", + "abortRebase": "中止变基" + }, + "remoteBranchDeleted": { + "title": "远程分支已删除", + "message": "远程跟踪分支“{{remoteName}}/{{branchName}}”已被删除,但本地分支仍然存在。\n\n⚠️ 删除本地分支会丢弃所有尚未推送的提交。", + "switchTo": "切换到 {{branchName}}", + "deleteLocalBranch": "删除本地分支", + "keepLocalBranch": "保留本地分支" + }, + "gitError": { + "titles": { + "none": "Git:操作已完成", + "nonFastForward": "Git:推送被拒绝", + "protectedBranch": "Git:受保护分支", + "authenticationFailed": "Git:身份验证失败", + "remoteBranchDeleted": "Git:远程分支已删除", + "uncommittedChanges": "Git:存在未提交的更改", + "networkError": "Git:网络错误", + "mergeConflicts": "Git:合并冲突", + "permissionDenied": "Git:权限不足", + "unknown": "Git:操作失败" + }, + "messages": { + "none": "Git 操作已成功完成。", + "nonFastForward": "远程分支包含更新的提交,因此推送被拒绝。", + "protectedBranch": "目标分支受保护,无法通过此操作更新。", + "authenticationFailed": "无法通过远程仓库的身份验证。请检查凭据后重试。", + "remoteBranchDeleted": "远程分支似乎已被删除或不再可用。", + "uncommittedChanges": "本地更改将被覆盖。请先提交或暂存这些更改再重试。", + "networkError": "无法连接到远程仓库。请检查网络连接。", + "mergeConflicts": "此操作产生了需要解决的合并冲突。", + "permissionDenied": "此仓库操作被拒绝。请检查你的仓库访问权限。", + "unknown": "Git 操作意外失败。请查看日志了解详情。" + }, + "operationFailed": "{{operation}}操作失败", + "dialogMessage": "{{operation}}失败:{{baseMessage}}\n\n{{detailMessage}}", + "stashHint": "\n\n提示:“暂存更改并继续”会暂存本地更改(包括未跟踪文件),重试该操作,然后询问是否恢复这些更改。", + "stashAndContinue": "暂存更改并继续", + "openGitLog": "打开 Git 日志", + "showCommandOutput": "显示命令输出" + }, + "gitAction": { + "title": "Git", + "transportError": "无法连接到本地 Git 服务。请等待应用启动完成后重试;如果问题持续存在,请重启 ORGII。" + } + }, + "destructiveDialog": { + "discard": "丢弃", + "cancel": "取消" + }, "clientOrigin": { "officialApp": "官方应用", "cli": "命令行", diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index 7a8182add6..28913c9d2d 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -1062,5 +1062,68 @@ "off": "关闭", "memberNote": "由管理员开启——无需打开该组织,符合条件的会话也会在后台上传。" } + }, + "web": { + "title": "ORG2 Web", + "sessionsNav": "会话", + "loadingSession": "正在加载会话…", + "readOnly": { + "headerTrailing": "Cloud · 只读", + "barLabel": "只读", + "barPlaceholder": "Cloud 会话为只读模式" + }, + "sessionsPage": { + "loadError": "无法加载会话列表", + "loading": "正在加载会话…", + "empty": "暂无已同步会话", + "select": "选择一个会话", + "emptyHint": "请先在 Desktop 中为会话开启 Cloud 同步。", + "selectHint": "请从侧边栏选择一个 Cloud 会话。", + "retry": "重试", + "organizationLoadErrorHint": "无法加载你的组织。请检查网络连接后重试。", + "organizationRetryingHint": "暂时无法连接云服务,正在自动重试…", + "organizationRefreshErrorHint": "无法刷新组织,正在显示上次成功加载的会话列表。", + "sessionRefreshErrorHint": "部分组织的会话无法刷新。", + "organizationSetupTitle": "连接一个组织", + "organizationSetupHint": "为团队创建组织,或使用邀请链接/邀请码加入已有组织。", + "organizationModeLabel": "组织连接方式", + "createOrganization": "创建组织", + "joinOrganization": "加入组织", + "organizationNamePlaceholder": "团队或组织名称", + "invitePlaceholder": "粘贴邀请链接或邀请码" + }, + "sessionPage": { + "notFound": "未找到会话", + "notFoundHint": "该会话可能已不再共享给此账号。", + "loading": "正在加载会话…", + "workstationLoading": "正在加载工作区…", + "workstationEmptyTitle": "暂无会话文件", + "workstationEmptySubtitle": "该会话尚未包含可读取的文件或编辑内容。", + "workstationLoadErrorFallback": "无法加载会话文件", + "workstationLoadErrorSubtitle": "Agent Station 回放仍可使用。", + "workstationRefreshFailedBanner": "刷新失败,正在显示已从会话事件中获取的文件。", + "chatTab": "Chat", + "workstationTab": "WorkStation", + "forkContext": "来自 {{owner}} 会话的分支", + "notesButton": "会话备注", + "notesTitle": "会话备注", + "notesEmpty": "暂无会话备注。" + }, + "login": { + "title": "登录 ORG2 Web", + "subtitle": "在浏览器中查看共享会话、聊天记录与回放。", + "continue": "使用 ORG2 Cloud 继续", + "hint": "使用与 Desktop 相同的 ORG2 Cloud 账号。" + }, + "authCallback": { + "failed": "登录失败", + "tryAgain": "重试", + "completing": "正在完成登录", + "missingCredentials": "登录回调缺少有效凭证。", + "missingIdentity": "登录 token 中缺少用户身份。" + }, + "sidebar": { + "signOut": "退出 {{name}}" + } } } diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 0d8f4dbc5f..27cb7f184c 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -731,7 +731,8 @@ "sent": "测试通知已发送", "permissionWarning": "测试通知发送失败,请检查权限。", "sendFailed": "无法发送测试通知。请检查通知权限后重试。", - "soundFailed": "无法播放通知音效,请检查系统音量。" + "soundFailed": "无法播放通知音效,请检查系统音量。", + "body": "这是一条来自 ORGII 的测试通知" }, "teamInbox": "团队收件箱", "teamInboxDesc": "来自队友的分配、提及和交接" diff --git a/src/index.tsx b/src/index.tsx index 4f368d7318..9a4ffa97a8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -205,6 +205,10 @@ async function initializeApp() { process.env.ORGII_DEV_EAGER_APP === "true" ? import(/* webpackMode: "eager" */ "@src/App") : import("@src/App"); + const codeEditorWebSocketPromise = + import("@src/api/realtime/codeEditorWebSocket").then( + ({ initializeCodeEditorWebSocket }) => initializeCodeEditorWebSocket() + ); // Clear stale opened repos from previous app session (main window only) // Secondary windows should not clear, as they'd wipe main window's registration @@ -236,6 +240,7 @@ async function initializeApp() { initTheme(), initializeTauriAPIs().then(() => applyWindowsNativeChromeAttribute()), initBackgroundImage(), + codeEditorWebSocketPromise, appModulePromise, ]); 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/modules/WorkStation/shared/StationModePill/index.tsx b/src/modules/WorkStation/shared/StationModePill/index.tsx index b06f50a32f..f8f1e652d7 100644 --- a/src/modules/WorkStation/shared/StationModePill/index.tsx +++ b/src/modules/WorkStation/shared/StationModePill/index.tsx @@ -64,21 +64,22 @@ const IconSwitchButton: React.FC = ({ ); }; -const StationModePill: React.FC = () => { - const [stationMode, setStationMode] = useAtom(stationModeAtom); +export interface StationModePillViewProps { + stationMode: StationMode; + onStationModeChange: (mode: StationMode) => void; +} +/** Controlled presentation shared by desktop and read-only remote hosts. */ +export const StationModePillView: React.FC = ({ + stationMode, + onStationModeChange, +}) => { const { t } = useTranslation("common"); const mySegment = t("terminology.myStation"); const agentSegment = t("terminology.agentStation"); const myStationShortcut = getShortcutKeys(MY_STATION_SHORTCUT_ID); const agentStationShortcut = getShortcutKeys(AGENT_STATION_SHORTCUT_ID); - const handleChange = useCallback( - (mode: StationMode) => { - setStationMode(mode); - }, - [setStationMode] - ); return (
{ tooltipLabel={t("actions.switchToStation", { station: mySegment })} icon={Laptop} selected={stationMode === "my-station"} - onClick={() => handleChange("my-station")} + onClick={() => onStationModeChange("my-station")} testId="station-mode-my-station" shortcut={myStationShortcut} /> @@ -99,7 +100,7 @@ const StationModePill: React.FC = () => { tooltipLabel={t("actions.switchToStation", { station: agentSegment })} icon={Infinity} selected={stationMode === "agent-station"} - onClick={() => handleChange("agent-station")} + onClick={() => onStationModeChange("agent-station")} testId="station-mode-agent-station" shortcut={agentStationShortcut} /> @@ -107,4 +108,19 @@ const StationModePill: React.FC = () => { ); }; +const StationModePill: React.FC = () => { + const [stationMode, setStationMode] = useAtom(stationModeAtom); + const handleChange = useCallback( + (mode: StationMode) => setStationMode(mode), + [setStationMode] + ); + + return ( + + ); +}; + export default StationModePill; diff --git a/src/modules/shared/layouts/OnboardingLoadingVideo.tsx b/src/modules/shared/layouts/OnboardingLoadingVideo.tsx index 18d1b8c0ab..b48cc61544 100644 --- a/src/modules/shared/layouts/OnboardingLoadingVideo.tsx +++ b/src/modules/shared/layouts/OnboardingLoadingVideo.tsx @@ -14,6 +14,14 @@ import RembrandtAnatomyLesson from "@src/assets/loading/Rembrandt_anatomy_lesson import VermeerAstronomer from "@src/assets/loading/Vermeer_astronomer.mp4"; import VermeerGeographer from "@src/assets/loading/Vermeer_geographer.mp4"; +import { ONBOARDING_LOADING_VIDEO_FRAME_PX } from "./onboardingTokens"; + +export { + ONBOARDING_LOADING_VIDEO_FRAME_PX, + ONBOARDING_LOADING_VIDEO_MAX_WIDTH_CLASS, + ONBOARDING_LOADING_VIDEO_WIDTH_CLASS, +} from "./onboardingTokens"; + const LOADING_VIDEOS = [ BoyleAnExperiment, BruegelAutumn, @@ -26,16 +34,6 @@ const LOADING_VIDEOS = [ VermeerGeographer, ]; -/** Square frame for the art clips (login, select-repo hero). */ -export const ONBOARDING_LOADING_VIDEO_FRAME_PX = 350; - -/** - * Tailwind width utilities matching {@link ONBOARDING_LOADING_VIDEO_FRAME_PX} - * (literal strings so JIT picks them up — update both when changing size). - */ -export const ONBOARDING_LOADING_VIDEO_WIDTH_CLASS = "w-[350px]"; -export const ONBOARDING_LOADING_VIDEO_MAX_WIDTH_CLASS = "max-w-[350px]"; - export const OnboardingLoadingVideo: React.FC = () => { const videoRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(() => diff --git a/src/modules/shared/layouts/index.ts b/src/modules/shared/layouts/index.ts index a9098d19d7..c876b1a5e1 100644 --- a/src/modules/shared/layouts/index.ts +++ b/src/modules/shared/layouts/index.ts @@ -14,10 +14,11 @@ export { OnboardingLayout } from "./OnboardingLayout"; export type { OnboardingLayoutProps } from "./OnboardingLayout"; export { OnboardingLoadingVideo } from "./OnboardingLoadingVideo"; export { + ONBOARDING_LOGIN_TOKENS, ONBOARDING_LOADING_VIDEO_FRAME_PX, ONBOARDING_LOADING_VIDEO_MAX_WIDTH_CLASS, ONBOARDING_LOADING_VIDEO_WIDTH_CLASS, -} from "./OnboardingLoadingVideo"; +} from "./onboardingTokens"; export { default as Section } from "./Section"; export type { SectionProps } from "./Section"; export { default as SubpageLayout } from "./SubpageLayout"; diff --git a/src/modules/shared/layouts/onboardingTokens.ts b/src/modules/shared/layouts/onboardingTokens.ts new file mode 100644 index 0000000000..604b6960a3 --- /dev/null +++ b/src/modules/shared/layouts/onboardingTokens.ts @@ -0,0 +1,18 @@ +/** Square frame for the desktop onboarding art clips. */ +export const ONBOARDING_LOADING_VIDEO_FRAME_PX = 350; + +/** + * Tailwind utilities matching {@link ONBOARDING_LOADING_VIDEO_FRAME_PX}. + * Literal strings keep the utilities visible to Tailwind's class scanner. + */ +export const ONBOARDING_LOADING_VIDEO_WIDTH_CLASS = "w-[350px]"; +export const ONBOARDING_LOADING_VIDEO_MAX_WIDTH_CLASS = "max-w-[350px]"; + +/** Shared sizing and composition tokens for authentication surfaces. */ +export const ONBOARDING_LOGIN_TOKENS = { + desktopColumnWidth: ONBOARDING_LOADING_VIDEO_WIDTH_CLASS, + responsiveColumnWidth: `w-full ${ONBOARDING_LOADING_VIDEO_MAX_WIDTH_CLASS}`, + contentStack: "flex flex-col items-center gap-6 text-center", + actionStack: "flex w-full flex-col items-center gap-2", + actionButton: "pointer-events-auto relative z-10 h-14 text-base font-medium", +} as const; 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 cde40f7031..1e946d6529 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 { ToolbarTooltip } from "@src/components/KeyboardShortcut/ToolbarTooltip"; import Select, { type SelectOption } from "@src/components/Select"; -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 ); @@ -381,8 +379,6 @@ export const WorkstationSidebarConnector: React.FC = () => { menuItems, sessionMap, subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, projectsWorkItemMenuItems, projectsProjectMap, projectsWorkItemMap, @@ -518,11 +514,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 a1f4d5b2ec..235a2f645d 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts @@ -59,13 +59,7 @@ export function useWorkstationSidebarSessionAndProjectMenuItems({ projectsSearchQuery, activeProjectOrgId, }: UseWorkstationSidebarSessionAndProjectMenuItemsParams) { - const { - menuItems, - sessionMap, - subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, - } = useSessionMenuItems({ + const { menuItems, sessionMap, subagentParentIds } = useSessionMenuItems({ sortedSessions, visitedSessions, repoPathToName, @@ -108,8 +102,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 a9c10957ab..d86aacd6e7 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts @@ -221,6 +221,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: [ @@ -230,8 +250,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", @@ -257,8 +277,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 68bed74c48..00e7c99152 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx @@ -35,10 +35,11 @@ import { } from "./menuSectionBuilders"; import { sessionMatchesOrgFilter } from "./orgFilter"; import { + type SessionPaginationPlan, appendSessionGroup, - getLoadMoreGroupId, - getUnifiedLoadMoreState, - isLoadMoreId, + getCategoryPaginationPlan, + getSessionPaginationPhase, + getUnifiedPaginationPlan, loadMoreRow, unifiedLoadMoreRow, } from "./paginationHelpers"; @@ -432,32 +433,43 @@ export function useSessionMenuItems({ ] ); - 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[]) => { @@ -517,7 +529,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)); @@ -624,7 +636,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 4b269e6492..6b1f563c59 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts @@ -134,11 +134,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); @@ -147,6 +151,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, @@ -161,7 +166,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 717a1deb67..197489fa9c 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts @@ -74,8 +74,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; @@ -94,5 +92,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 198ea16b92..cf2e57fed7 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", () => ({ @@ -103,4 +115,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 a90991a0cc..bc289c17fe 100644 --- a/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx +++ b/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx @@ -92,6 +92,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; /** @@ -270,6 +274,8 @@ const NavigationSidebar: React.FC = React.memo( isLoading = false, loadingContent, solidSurface = false, + includeTrafficLightSpace = true, + showCollapseButton = true, collapsibleSections = false, collapsedSectionIds, onCollapsedSectionsChange, @@ -418,6 +424,8 @@ const NavigationSidebar: React.FC = React.memo( hostTopBarLeadingContent={hostTopBarLeadingContent} macTopBarFollowingContent={macTopBarFollowingContent} solidSurface={solidSurface} + includeTrafficLightSpace={includeTrafficLightSpace} + showCollapseButton={showCollapseButton} > {preListContent} diff --git a/src/util/data/formatters/date.ts b/src/util/data/formatters/date.ts index e7f783305b..2e09bf19c2 100644 --- a/src/util/data/formatters/date.ts +++ b/src/util/data/formatters/date.ts @@ -17,6 +17,7 @@ import { getCurrentTimezone, resolveTimeZoneForIntl, } from "@src/config/timezone"; +import i18n from "@src/i18n"; import { parseApiDate } from "./dateCore"; @@ -31,7 +32,8 @@ export { parseApiDate }; */ export const formatDate = ( dateString: string | null | undefined, - options?: Intl.DateTimeFormatOptions + options?: Intl.DateTimeFormatOptions, + locale?: string ): string => { if (!dateString) return "—"; @@ -55,7 +57,7 @@ export const formatDate = ( formatOptions.timeZone = timezone === "utc" ? "UTC" : timezone; } - return date.toLocaleString("en-US", formatOptions); + return date.toLocaleString(resolveDateLocale(locale), formatOptions); } catch { return "—"; } @@ -67,7 +69,10 @@ export const formatDate = ( * @param dateString - The date string from the API (assumed UTC if no timezone) * @returns A formatted time string */ -export const formatTime = (dateString: string | null | undefined): string => { +export const formatTime = ( + dateString: string | null | undefined, + locale?: string +): string => { if (!dateString) return "—"; try { @@ -85,7 +90,7 @@ export const formatTime = (dateString: string | null | undefined): string => { options.timeZone = timezone === "utc" ? "UTC" : timezone; } - return date.toLocaleTimeString("en-US", options); + return date.toLocaleTimeString(resolveDateLocale(locale), options); } catch { return "—"; } @@ -95,12 +100,36 @@ export const formatTime = (dateString: string | null | undefined): string => { * Map app language codes to BCP-47 locale tags for {@link Intl} (month names, time). */ export function toIntlLocaleTag(language: string | undefined): string { - if (!language) return "en-US"; - if (language === "en") return "en-US"; - if (language === "zh") return "zh-CN"; - if (language === "ja") return "ja-JP"; - if (language === "ko") return "ko-KR"; - return language; + const mapped = + language === "en" + ? "en-US" + : language === "zh" + ? "zh-CN" + : language === "zh-Hant" + ? "zh-Hant-TW" + : language === "ja" + ? "ja-JP" + : language === "ko" + ? "ko-KR" + : language; + if (!mapped) return "en-US"; + + try { + const canonical = Intl.getCanonicalLocales(mapped)[0]; + return canonical && + Intl.DateTimeFormat.supportedLocalesOf([canonical]).length > 0 + ? canonical + : "en-US"; + } catch { + return "en-US"; + } +} + +/** Explicit locale wins; otherwise follow the currently resolved app language. */ +export function resolveDateLocale(locale?: string): string { + return toIntlLocaleTag( + locale ?? i18n.resolvedLanguage ?? i18n.language ?? "en" + ); } function dateKeyInTimezone(date: Date, timeZone: string | undefined): string { @@ -163,11 +192,8 @@ export function getLocalDayDiff(date: Date, now: Date = new Date()): number { ); } -export function formatLocalClock( - date: Date, - locale: string | undefined = "en-US" -): string { - return date.toLocaleString(locale, { +export function formatLocalClock(date: Date, locale?: string): string { + return date.toLocaleString(resolveDateLocale(locale), { hour: "numeric", minute: "2-digit", }); @@ -181,33 +207,42 @@ export function formatLocalMonthDay( monthStyle?: "short" | "long"; } ): string { - const locale = options && "locale" in options ? options.locale : "en-US"; - const month = date.toLocaleString(locale, { + const locale = resolveDateLocale(options?.locale); + return date.toLocaleDateString(locale, { month: options?.monthStyle ?? "short", + day: "numeric", + ...(options?.includeYear ? { year: "numeric" as const } : {}), }); - const day = date.getDate(); - if (options?.includeYear) { - return `${month} ${day}, ${date.getFullYear()}`; - } - return `${month} ${day}`; } export function formatRelativeElapsedShort( date: Date, - now: Date = new Date() + now: Date = new Date(), + locale?: string ): string { const diffSec = Math.floor((now.getTime() - date.getTime()) / 1000); const diffMin = Math.floor(diffSec / 60); const diffHr = Math.floor(diffMin / 60); - if (diffSec < 60) return "just now"; - if (diffMin < 60) return `${diffMin}m ago`; - return `${diffHr}h ago`; + const resolvedLocale = resolveDateLocale(locale); + if (resolvedLocale.toLowerCase().startsWith("en")) { + if (diffSec < 60) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + return `${diffHr}h ago`; + } + + const formatter = new Intl.RelativeTimeFormat(resolvedLocale, { + numeric: "auto", + style: "narrow", + }); + if (diffSec < 60) return formatter.format(0, "second"); + if (diffMin < 60) return formatter.format(-diffMin, "minute"); + return formatter.format(-diffHr, "hour"); } export interface FormatSmartDateTimeOptions { /** Label for the previous calendar day (from i18n). Default: "Yesterday" */ yesterdayLabel?: string; - /** Locale for month names and time. Default: en-US */ + /** Locale for month names and time. Defaults to the resolved app language. */ locale?: string; } @@ -229,8 +264,14 @@ export function formatSmartDateTime( if (!date) return "—"; const timeZone = resolveTimeZoneForIntl(); - const locale = options?.locale ?? "en-US"; - const yesterdayLabel = options?.yesterdayLabel ?? "Yesterday"; + const locale = resolveDateLocale(options?.locale); + const yesterdayLabel = + options?.yesterdayLabel ?? + String( + i18n.t("common:relativeDate.yesterday", { + defaultValue: "Yesterday", + }) + ); const now = new Date(); const todayKey = dateKeyInTimezone(now, timeZone); @@ -289,7 +330,7 @@ export interface FormatCalendarDateLabelOptions { todayLabel?: string; /** Translated "Yesterday" label (from i18n). Default: "Yesterday" */ yesterdayLabel?: string; - /** BCP-47 locale tag for month names. Default: "en-US" */ + /** BCP-47 locale tag for month names. Defaults to the resolved app language. */ locale?: string; /** Month display style for non-relative dates. Default: `short`. */ monthStyle?: "short" | "long"; @@ -307,9 +348,17 @@ export function formatCalendarDateLabel( if (!date || Number.isNaN(date.getTime())) return ""; const timeZone = resolveTimeZoneForIntl(); - const locale = options?.locale ?? "en-US"; - const todayLabel = options?.todayLabel ?? "Today"; - const yesterdayLabel = options?.yesterdayLabel ?? "Yesterday"; + const locale = resolveDateLocale(options?.locale); + const todayLabel = + options?.todayLabel ?? + String(i18n.t("common:relativeDate.today", { defaultValue: "Today" })); + const yesterdayLabel = + options?.yesterdayLabel ?? + String( + i18n.t("common:relativeDate.yesterday", { + defaultValue: "Yesterday", + }) + ); const monthStyle = options?.monthStyle ?? "short"; const now = new Date(); @@ -349,7 +398,7 @@ export interface FormatReplayDateLabelOptions { todayLabel?: string; /** Translated "Yesterday" label (from i18n). Default: "Yesterday" */ yesterdayLabel?: string; - /** BCP-47 locale tag for month names. Default: "en-US" */ + /** BCP-47 locale tag for month names. Defaults to the resolved app language. */ locale?: string; /** * Whether to include seconds in the time portion. The kanban replay bar @@ -386,9 +435,17 @@ export function formatReplayDateLabel( if (!date || Number.isNaN(date.getTime())) return ""; const timeZone = resolveTimeZoneForIntl(); - const locale = options?.locale ?? "en-US"; - const todayLabel = options?.todayLabel ?? "Today"; - const yesterdayLabel = options?.yesterdayLabel ?? "Yesterday"; + const locale = resolveDateLocale(options?.locale); + const todayLabel = + options?.todayLabel ?? + String(i18n.t("common:relativeDate.today", { defaultValue: "Today" })); + const yesterdayLabel = + options?.yesterdayLabel ?? + String( + i18n.t("common:relativeDate.yesterday", { + defaultValue: "Yesterday", + }) + ); const withSeconds = options?.withSeconds ?? true; const monthStyle = options?.monthStyle ?? "long"; @@ -514,30 +571,16 @@ export const compareDates = ( * @param timestamp - Unix timestamp in seconds * @returns Formatted string like "Jan 05, 2025, 14:30" */ -export const formatDateTime = (timestamp: number): string => { +export const formatDateTime = (timestamp: number, locale?: string): string => { const date = new Date(timestamp * 1000); - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - - const month = months[date.getMonth()]; - const day = date.getDate().toString().padStart(2, "0"); - const year = date.getFullYear(); - const hours = date.getHours().toString().padStart(2, "0"); - const minutes = date.getMinutes().toString().padStart(2, "0"); - - return `${month} ${day}, ${year}, ${hours}:${minutes}`; + return new Intl.DateTimeFormat(resolveDateLocale(locale), { + month: "short", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); }; /** @@ -545,7 +588,7 @@ export const formatDateTime = (timestamp: number): string => { * @param dateString - Date string in MM-DD-YYYY format * @returns Formatted string like "January 5, 2025" */ -export function paymentFormatDate(dateString: string): string { +export function paymentFormatDate(dateString: string, locale?: string): string { if (!dateString) return ""; const parts = dateString.split("-"); @@ -556,7 +599,7 @@ export function paymentFormatDate(dateString: string): string { if (isNaN(date.getTime())) return ""; - return new Intl.DateTimeFormat("en-US", { + return new Intl.DateTimeFormat(resolveDateLocale(locale), { year: "numeric", month: "long", day: "numeric", diff --git a/src/util/data/formatters/dateLocalDisplay.test.ts b/src/util/data/formatters/dateLocalDisplay.test.ts index 54c9b619ce..4af36fc721 100644 --- a/src/util/data/formatters/dateLocalDisplay.test.ts +++ b/src/util/data/formatters/dateLocalDisplay.test.ts @@ -1,4 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import i18n, { i18nReady } from "@src/i18n"; +import zhCommon from "@src/i18n/locales/zh/common.json"; import { addLocalDays, @@ -9,9 +20,23 @@ import { getLocalDayDiff, getStartOfLocalDay, isSameLocalDay, + toIntlLocaleTag, } from "./date"; +beforeAll(() => { + i18n.addResourceBundle("zh", "common", zhCommon, true, true); +}); + +afterAll(async () => { + await i18n.changeLanguage("en"); +}); + describe("local date display helpers", () => { + beforeEach(async () => { + await i18nReady; + await i18n.changeLanguage("en"); + }); + it("keeps local calendar operations aligned across UI surfaces", () => { const date = new Date(2026, 0, 5, 15, 45, 30); @@ -32,9 +57,10 @@ describe("local date display helpers", () => { ); }); - it("preserves browser-locale month labels when locale is explicitly undefined", () => { + it("uses the resolved app locale when no explicit locale is provided", async () => { const date = new Date(2026, 1, 25); - const expected = new Intl.DateTimeFormat(undefined, { + await i18n.changeLanguage("zh"); + const expected = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", }).format(date); @@ -42,6 +68,32 @@ describe("local date display helpers", () => { expect(formatLocalMonthDay(date, { locale: undefined })).toBe(expected); }); + it("lets an explicit locale override i18n and falls back for unknown locales", async () => { + const date = new Date(2026, 1, 25); + await i18n.changeLanguage("zh"); + + expect(formatLocalMonthDay(date, { locale: "fr" })).toBe( + new Intl.DateTimeFormat("fr", { + month: "short", + day: "numeric", + }).format(date) + ); + expect(toIntlLocaleTag("not_a_locale")).toBe("en-US"); + }); + + it("localizes compact relative elapsed labels", async () => { + const now = new Date(2026, 1, 25, 14, 30, 0); + const date = new Date(2026, 1, 25, 14, 25, 0); + await i18n.changeLanguage("zh"); + + expect(formatRelativeElapsedShort(date, now)).toBe( + new Intl.RelativeTimeFormat("zh-CN", { + numeric: "auto", + style: "narrow", + }).format(-5, "minute") + ); + }); + it("formats relative elapsed labels used by Inbox", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 1, 25, 14, 30, 0)); diff --git a/src/util/time/__tests__/formatRelativeTime.test.ts b/src/util/time/__tests__/formatRelativeTime.test.ts index f713da2790..942862fa6a 100644 --- a/src/util/time/__tests__/formatRelativeTime.test.ts +++ b/src/util/time/__tests__/formatRelativeTime.test.ts @@ -1,4 +1,16 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import i18n, { i18nReady } from "@src/i18n"; +import zhCommon from "@src/i18n/locales/zh/common.json"; import { formatRelativeTime } from "../formatRelativeTime"; @@ -31,11 +43,24 @@ const OLD_INSTANT = Date.parse("2026-07-30T00:00:00Z"); /** Far enough past OLD_INSTANT to land in the ">7 days" date fallback. */ const NOW = Date.parse("2026-08-20T00:00:00Z"); +beforeAll(() => { + i18n.addResourceBundle("zh", "common", zhCommon, true, true); +}); + +afterAll(async () => { + await i18n.changeLanguage("en"); +}); + afterEach(() => { vi.useRealTimers(); getCurrentTimezoneMock.mockReturnValue("auto"); }); +beforeEach(async () => { + await i18nReady; + await i18n.changeLanguage("en"); +}); + describe("formatRelativeTime date fallback", () => { it("renders in the system zone when the preference is auto", () => { vi.useFakeTimers(); @@ -73,4 +98,26 @@ describe("formatRelativeTime date fallback", () => { expect(formatRelativeTime(OLD_INSTANT, "short")).toBe("2 days ago"); }); + + it("uses the resolved non-English language for relative phrasing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(OLD_INSTANT + 2 * 24 * 60 * 60 * 1000); + await i18n.changeLanguage("zh"); + + expect(formatRelativeTime(OLD_INSTANT, "short")).toBe( + new Intl.RelativeTimeFormat("zh-CN", { + numeric: "auto", + style: "short", + }).format(-2, "day") + ); + }); + + it("keeps English compatibility copy for unsupported explicit locales", () => { + vi.useFakeTimers(); + vi.setSystemTime(OLD_INSTANT + 2 * 24 * 60 * 60 * 1000); + + expect(formatRelativeTime(OLD_INSTANT, "short", "not_a_locale")).toBe( + "2 days ago" + ); + }); }); diff --git a/src/util/time/formatRelativeTime.ts b/src/util/time/formatRelativeTime.ts index d0b3a79750..d86dcf082e 100644 --- a/src/util/time/formatRelativeTime.ts +++ b/src/util/time/formatRelativeTime.ts @@ -1,4 +1,5 @@ import { resolveTimeZoneForIntl } from "@src/config/timezone"; +import { resolveDateLocale } from "@src/util/data/formatters/date"; export type RelativeTimeStyle = "short" | "compact" | "long" | "nano" | "issue"; @@ -29,16 +30,30 @@ function toMs(timestamp: number | string | null | undefined): number | null { * - "long": "just now", "2 minutes ago", "3 hours ago", "1 month ago", "2 years ago" * - "nano": "Now", "5m", "3h", "2d", "1w", "3mo", "1y" * - "issue": "today", "yesterday", "5d ago", "2mo ago", "1y ago" + * @param locale - Optional BCP-47 locale. Defaults to i18n.resolvedLanguage. */ export function formatRelativeTime( timestamp: number | string | null | undefined, - style: RelativeTimeStyle = "short" + style: RelativeTimeStyle = "short", + locale?: string ): string { const ms = toMs(timestamp); if (ms === null) return ""; const diffMs = Date.now() - ms; - if (diffMs < 0) return style === "short" ? "Now" : "just now"; + const resolvedLocale = resolveDateLocale(locale); + const useEnglishCompatibilityCopy = resolvedLocale + .toLowerCase() + .startsWith("en"); + if (diffMs < 0) { + if (useEnglishCompatibilityCopy) { + return style === "short" ? "Now" : "just now"; + } + return new Intl.RelativeTimeFormat(resolvedLocale, { + numeric: "auto", + style: style === "long" ? "long" : "narrow", + }).format(0, "second"); + } const diffSec = Math.floor(diffMs / SEC); const diffMin = Math.floor(diffMs / MIN); @@ -48,6 +63,37 @@ export function formatRelativeTime( const diffMonth = Math.floor(diffMs / MONTH); const diffYear = Math.floor(diffMs / YEAR); + if (!useEnglishCompatibilityCopy) { + const formatter = new Intl.RelativeTimeFormat(resolvedLocale, { + numeric: "auto", + style: style === "long" ? "long" : style === "short" ? "short" : "narrow", + }); + + if (style === "short") { + if (diffSec < 60) return formatter.format(0, "second"); + if (diffMin < 60) return formatter.format(-diffMin, "minute"); + if (diffHr < 24) return formatter.format(-diffHr, "hour"); + if (diffDay < 7) return formatter.format(-diffDay, "day"); + return new Date(ms).toLocaleDateString(resolvedLocale, { + timeZone: resolveTimeZoneForIntl(), + }); + } + + if (style === "issue") { + if (diffDay < 30) return formatter.format(-diffDay, "day"); + if (diffMonth < 12) return formatter.format(-diffMonth, "month"); + return formatter.format(-diffYear, "year"); + } + + if (diffSec < 60) return formatter.format(0, "second"); + if (diffMin < 60) return formatter.format(-diffMin, "minute"); + if (diffHr < 24) return formatter.format(-diffHr, "hour"); + if (diffDay < 7) return formatter.format(-diffDay, "day"); + if (diffWeek < 4) return formatter.format(-diffWeek, "week"); + if (diffMonth < 12) return formatter.format(-diffMonth, "month"); + return formatter.format(-diffYear, "year"); + } + if (style === "short") { if (diffSec < 60) return "Now"; if (diffMin < 60) return `${diffMin} min ago`; @@ -57,7 +103,7 @@ export function formatRelativeTime( // Honor the explicit timezone preference like every other formatter // (`resolveTimeZoneForIntl` answers undefined for "auto", which Intl // treats as the system zone). - return new Date(ms).toLocaleDateString(undefined, { + return new Date(ms).toLocaleDateString(resolvedLocale, { timeZone: resolveTimeZoneForIntl(), }); } diff --git a/src/web/WebApp.tsx b/src/web/WebApp.tsx new file mode 100644 index 0000000000..854a22cdc9 --- /dev/null +++ b/src/web/WebApp.tsx @@ -0,0 +1,123 @@ +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 { 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"; +import { WebShell } from "./shell/WebShell"; + +const WebSessionPage = lazy(() => + // 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, + })) +); + +function SessionRoute({ replayInitially = false }) { + const { t } = useTranslation("navigation"); + return ( + + {t("web.loadingSession")} +
+ } + > + + + ); +} + +function RequireCloudAuth() { + const auth = useAtomValue(org2CloudAuthAtom); + return auth ? : ; +} + +function WebCloudRuntime() { + useOrg2CloudOrgs(); + useOrg2CloudRosterReconcile(); + 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.test.ts b/src/web/features/auth/WebAuthCallbackPage.test.ts new file mode 100644 index 0000000000..6ad636869a --- /dev/null +++ b/src/web/features/auth/WebAuthCallbackPage.test.ts @@ -0,0 +1,97 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { WebAuthCallbackPage } from "./WebAuthCallbackPage"; +import { WEB_AUTH_STATE_STORAGE_KEY } from "./webAuthFlowState"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), + setAuth: vi.fn(), +})); + +vi.mock("jotai", () => ({ + useSetAtom: () => mocks.setAuth, +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => mocks.navigate, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/components/Button", () => ({ + default: ({ children }: { children: React.ReactNode }) => + React.createElement("button", null, children), +})); + +vi.mock("@src/components/Placeholder", () => ({ + Placeholder: ({ title }: { title: string }) => + React.createElement("div", { "data-error": true }, title), +})); + +function accessToken(userId: string): string { + return `header.${btoa(JSON.stringify({ sub: userId }))}.signature`; +} + +describe("WebAuthCallbackPage", () => { + const roots: Array> = []; + + beforeEach(() => { + mocks.navigate.mockReset(); + mocks.setAuth.mockReset(); + sessionStorage.clear(); + window.history.replaceState(null, "", "/auth/callback"); + }); + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("commits credentials only for the matching one-time callback state", async () => { + sessionStorage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected"); + const token = accessToken("user-1"); + window.history.replaceState( + null, + "", + `/auth/callback?state=expected#access_token=${token}&refresh_token=refresh&expires_at=2000000000` + ); + const root = createSmokeRoot(); + roots.push(root); + + await root.render(React.createElement(WebAuthCallbackPage)); + + expect(mocks.setAuth).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + accessToken: token, + refreshToken: "refresh", + expiresAt: 2_000_000_000, + }) + ); + expect(sessionStorage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBeNull(); + expect(mocks.navigate).toHaveBeenCalledWith("/sessions", { + replace: true, + }); + }); + + it("rejects a token fragment that is not correlated to this tab", async () => { + const token = accessToken("attacker"); + window.history.replaceState( + null, + "", + `/auth/callback?state=untrusted#access_token=${token}&refresh_token=refresh&expires_at=2000000000` + ); + const root = createSmokeRoot(); + roots.push(root); + + await root.render(React.createElement(WebAuthCallbackPage)); + + expect(mocks.setAuth).not.toHaveBeenCalled(); + expect(root.container.querySelector("[data-error]")).not.toBeNull(); + }); +}); diff --git a/src/web/features/auth/WebAuthCallbackPage.tsx b/src/web/features/auth/WebAuthCallbackPage.tsx new file mode 100644 index 0000000000..1ae0b6eece --- /dev/null +++ b/src/web/features/auth/WebAuthCallbackPage.tsx @@ -0,0 +1,100 @@ +import { useSetAtom } from "jotai"; +import React, { useEffect, useMemo, useRef } from "react"; +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 { + consumeWebAuthCallbackState, + validateWebAuthCallbackState, +} from "./webAuthFlowState"; + +export function WebAuthCallbackPage() { + const { t } = useTranslation("navigation"); + const setAuth = useSetAtom(org2CloudAuthAtom); + const navigate = useNavigate(); + const committedRef = useRef(false); + const result = useMemo(() => { + const validatedState = validateWebAuthCallbackState(window.location.href); + const callback = validatedState + ? parseAuthCallbackFragment( + window.location.href, + validatedState.expectedCallbackUrl + ) + : null; + if (!callback) { + return { + ok: false, + error: t("web.authCallback.missingCredentials"), + } as const; + } + const userId = decodeJwtSub(callback.accessToken); + if (!userId) { + return { + ok: false, + error: t("web.authCallback.missingIdentity"), + } as const; + } + return { + ok: true, + callback, + userId, + state: validatedState!.state, + } as const; + }, [t]); + + useEffect(() => { + if (!result.ok || committedRef.current) return; + if (!consumeWebAuthCallbackState(result.state)) { + navigate("/login", { replace: true }); + return; + } + committedRef.current = true; + const endpoint = getCloudEndpoint(); + window.history.replaceState(null, "", "/auth/callback"); + setAuth({ + 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.ok) { + 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..52261760dc --- /dev/null +++ b/src/web/features/auth/WebLoginPage.tsx @@ -0,0 +1,83 @@ +import { useAtomValue } from "jotai"; +import React, { useCallback, useState } 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"; + +import { createWebAuthCallbackUrl } from "./webAuthFlowState"; + +export function WebLoginPage() { + const { t } = useTranslation("navigation"); + const auth = useAtomValue(org2CloudAuthAtom); + const [startError, setStartError] = useState(null); + const startSignIn = useCallback(() => { + try { + setStartError(null); + window.location.assign( + buildOrg2CloudLoginUrl(createWebAuthCallbackUrl()) + ); + } catch { + setStartError(t("web.authCallback.failed")); + } + }, [t]); + if (auth) return ; + + return ( +
+ +
+ +

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

+

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

+

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

+
+ +
+ + {startError ? ( +

+ {startError} +

+ ) : null} +

+ {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/features/auth/webAuthFlowState.test.ts b/src/web/features/auth/webAuthFlowState.test.ts new file mode 100644 index 0000000000..b2c6f1997a --- /dev/null +++ b/src/web/features/auth/webAuthFlowState.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + WEB_AUTH_STATE_STORAGE_KEY, + consumeWebAuthCallbackState, + createWebAuthCallbackUrl, + validateWebAuthCallbackState, +} from "./webAuthFlowState"; + +function memoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +} + +describe("web auth callback state", () => { + it("creates a high-entropy callback correlation stored for this tab", () => { + const storage = memoryStorage(); + const callbackUrl = createWebAuthCallbackUrl({ + origin: "https://app.example.com", + storage, + fillRandom: (buffer) => { + buffer.fill(0xab); + return buffer; + }, + }); + const state = "ab".repeat(32); + + expect(callbackUrl).toBe( + `https://app.example.com/auth/callback?state=${state}` + ); + expect(storage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBe(state); + }); + + it("rejects missing, duplicate, or mismatched callback state", () => { + const storage = memoryStorage(); + storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected"); + + for (const href of [ + "https://app.example.com/auth/callback", + "https://app.example.com/auth/callback?state=other", + "https://app.example.com/auth/callback?state=expected&state=expected", + ]) { + expect( + validateWebAuthCallbackState(href, { + origin: "https://app.example.com", + storage, + }) + ).toBeNull(); + } + expect(storage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBe("expected"); + }); + + it("accepts the matching callback and consumes it exactly once", () => { + const storage = memoryStorage(); + storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected"); + + expect( + validateWebAuthCallbackState( + "https://app.example.com/auth/callback?state=expected#access_token=x", + { origin: "https://app.example.com", storage } + ) + ).toEqual({ + expectedCallbackUrl: + "https://app.example.com/auth/callback?state=expected", + state: "expected", + }); + expect(consumeWebAuthCallbackState("expected", storage)).toBe(true); + expect(consumeWebAuthCallbackState("expected", storage)).toBe(false); + }); +}); diff --git a/src/web/features/auth/webAuthFlowState.ts b/src/web/features/auth/webAuthFlowState.ts new file mode 100644 index 0000000000..e5391ac29e --- /dev/null +++ b/src/web/features/auth/webAuthFlowState.ts @@ -0,0 +1,97 @@ +const WEB_AUTH_STATE_BYTE_LENGTH = 32; + +export const WEB_AUTH_STATE_STORAGE_KEY = "orgii:web-auth-state"; + +type WebAuthStateStorage = Pick; + +interface CreateWebAuthCallbackUrlOptions { + origin?: string; + storage?: WebAuthStateStorage; + fillRandom?: (buffer: Uint8Array) => Uint8Array; +} + +interface ValidateWebAuthCallbackStateOptions { + origin?: string; + storage?: WebAuthStateStorage; +} + +function browserStorage(): WebAuthStateStorage { + return window.sessionStorage; +} + +function browserRandom(buffer: Uint8Array): Uint8Array { + return window.crypto.getRandomValues(buffer); +} + +function randomState(fillRandom: (buffer: Uint8Array) => Uint8Array): string { + const bytes = fillRandom(new Uint8Array(WEB_AUTH_STATE_BYTE_LENGTH)); + if (bytes.length !== WEB_AUTH_STATE_BYTE_LENGTH) { + throw new Error("Web auth state generator returned the wrong byte length"); + } + return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join( + "" + ); +} + +/** Start one browser sign-in episode and bind its callback to this tab. */ +export function createWebAuthCallbackUrl( + options: CreateWebAuthCallbackUrlOptions = {} +): string { + const origin = options.origin ?? window.location.origin; + const storage = options.storage ?? browserStorage(); + const state = randomState(options.fillRandom ?? browserRandom); + storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, state); + + const callbackUrl = new URL("/auth/callback", origin); + callbackUrl.searchParams.set("state", state); + return callbackUrl.toString(); +} + +export interface ValidatedWebAuthCallbackState { + expectedCallbackUrl: string; + state: string; +} + +/** + * Correlate a callback with the sign-in episode started in this tab. + * Consumption is separate so malformed credentials cannot burn a valid state. + */ +export function validateWebAuthCallbackState( + callbackHref: string, + options: ValidateWebAuthCallbackStateOptions = {} +): ValidatedWebAuthCallbackState | null { + const origin = options.origin ?? window.location.origin; + const storage = options.storage ?? browserStorage(); + const expectedState = storage.getItem(WEB_AUTH_STATE_STORAGE_KEY); + if (!expectedState) return null; + + let callbackUrl: URL; + try { + callbackUrl = new URL(callbackHref); + } catch { + return null; + } + const callbackStates = callbackUrl.searchParams.getAll("state"); + if (callbackStates.length !== 1 || callbackStates[0] !== expectedState) { + return null; + } + + const expectedCallbackUrl = new URL("/auth/callback", origin); + expectedCallbackUrl.searchParams.set("state", expectedState); + return { + expectedCallbackUrl: expectedCallbackUrl.toString(), + state: expectedState, + }; +} + +/** Consume a previously validated state exactly once. */ +export function consumeWebAuthCallbackState( + expectedState: string, + storage: WebAuthStateStorage = browserStorage() +): boolean { + if (storage.getItem(WEB_AUTH_STATE_STORAGE_KEY) !== expectedState) { + return false; + } + storage.removeItem(WEB_AUTH_STATE_STORAGE_KEY); + return true; +} diff --git a/src/web/features/sessions/WebCloudRealtimeScope.test.ts b/src/web/features/sessions/WebCloudRealtimeScope.test.ts new file mode 100644 index 0000000000..d6a1167569 --- /dev/null +++ b/src/web/features/sessions/WebCloudRealtimeScope.test.ts @@ -0,0 +1,93 @@ +/** @vitest-environment jsdom */ +import { Provider, createStore } from "jotai"; +import React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + org2CloudOrgsAtom, + sidebarActiveCloudOrgIdAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { + WebCloudRealtimeScope, + resolveWebActiveCloudOrgId, +} from "./WebCloudRealtimeScope"; + +const mocks = vi.hoisted(() => ({ + useRealtime: vi.fn(), +})); + +vi.mock("@src/features/Org2Cloud/useOrg2CloudRealtime", () => ({ + useOrg2CloudRealtime: () => mocks.useRealtime(), +})); + +describe("resolveWebActiveCloudOrgId", () => { + const availableOrgIds = ["org-1", "org two"]; + + it("prefers a valid session route over query and fallback scopes", () => { + expect( + resolveWebActiveCloudOrgId({ + pathname: "/sessions/org%20two/session-1/replay", + search: "?org=org-1", + availableOrgIds, + }) + ).toBe("org two"); + }); + + it("uses a valid query scope, then the first available organization", () => { + expect( + resolveWebActiveCloudOrgId({ + pathname: "/sessions", + search: "?org=org%20two", + availableOrgIds, + }) + ).toBe("org two"); + expect( + resolveWebActiveCloudOrgId({ + pathname: "/sessions/missing/session-1", + search: "?org=missing", + availableOrgIds, + }) + ).toBe("org-1"); + }); +}); + +describe("WebCloudRealtimeScope", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + mocks.useRealtime.mockReset(); + }); + + it("projects the route org for Realtime and clears it on teardown", async () => { + const store = createStore(); + store.set(org2CloudOrgsAtom, [ + { orgId: "org-1", name: "One", role: "member" }, + { orgId: "org-2", name: "Two", role: "member" }, + ]); + const root = createSmokeRoot(); + roots.push(root); + + await root.render( + React.createElement( + Provider, + { store }, + React.createElement( + MemoryRouter, + { initialEntries: ["/sessions/org-2/session-1"] }, + React.createElement(WebCloudRealtimeScope) + ) + ) + ); + + expect(store.get(sidebarActiveCloudOrgIdAtom)).toBe("org-2"); + expect(mocks.useRealtime).toHaveBeenCalled(); + + await root.unmount(); + roots.splice(roots.indexOf(root), 1); + expect(store.get(sidebarActiveCloudOrgIdAtom)).toBeNull(); + }); +}); diff --git a/src/web/features/sessions/WebCloudRealtimeScope.tsx b/src/web/features/sessions/WebCloudRealtimeScope.tsx new file mode 100644 index 0000000000..5d59dab53b --- /dev/null +++ b/src/web/features/sessions/WebCloudRealtimeScope.tsx @@ -0,0 +1,69 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useLayoutEffect } from "react"; +import { useLocation } from "react-router-dom"; + +import { + org2CloudOrgsAtom, + sidebarActiveCloudOrgIdAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { useOrg2CloudRealtime } from "@src/features/Org2Cloud/useOrg2CloudRealtime"; + +function decodedPathSegment(value: string | undefined): string | null { + if (!value) return null; + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +/** Resolve the one cloud organization represented by the current Web route. */ +export function resolveWebActiveCloudOrgId({ + pathname, + search, + availableOrgIds, +}: { + pathname: string; + search: string; + availableOrgIds: readonly string[]; +}): string | null { + const available = new Set(availableOrgIds); + const routeOrgId = decodedPathSegment( + pathname.match(/^\/sessions\/([^/]+)\/[^/]+(?:\/replay)?\/?$/)?.[1] + ); + if (routeOrgId && available.has(routeOrgId)) return routeOrgId; + + const requestedOrgId = new URLSearchParams(search).get("org"); + if (requestedOrgId && available.has(requestedOrgId)) return requestedOrgId; + return availableOrgIds[0] ?? null; +} + +/** + * Singleton owner for the Web app's active-org projection and Realtime lease. + * It lives inside the auth-keyed sessions provider, so sign-out tears both + * down together. + */ +export function WebCloudRealtimeScope() { + const location = useLocation(); + const orgs = useAtomValue(org2CloudOrgsAtom); + const setActiveOrgId = useSetAtom(sidebarActiveCloudOrgIdAtom); + const activeOrgId = resolveWebActiveCloudOrgId({ + pathname: location.pathname, + search: location.search, + availableOrgIds: orgs.map((org) => org.orgId), + }); + + useLayoutEffect(() => { + setActiveOrgId(activeOrgId); + }, [activeOrgId, setActiveOrgId]); + + useLayoutEffect( + () => () => { + setActiveOrgId(null); + }, + [setActiveOrgId] + ); + + useOrg2CloudRealtime(); + return null; +} diff --git a/src/web/features/sessions/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/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/WebOrganizationOnboarding.tsx b/src/web/features/sessions/WebOrganizationOnboarding.tsx new file mode 100644 index 0000000000..2235a536b5 --- /dev/null +++ b/src/web/features/sessions/WebOrganizationOnboarding.tsx @@ -0,0 +1,225 @@ +import { Building2 } from "lucide-react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import Input from "@src/components/Input"; +import Message from "@src/components/Message"; +import { cloudManagementErrorMessage } from "@src/features/Org2Cloud/org2CloudOrgManagement"; +import { + CloudOrgMembershipActionFailure, + useCloudOrgMembershipActions, +} from "@src/features/Org2Cloud/useCloudOrgMembershipActions"; + +type OrganizationMode = "create" | "join"; + +function membershipActionErrorMessage( + error: unknown, + translate: (key: string) => string +): string { + if (error instanceof CloudOrgMembershipActionFailure) { + if (error.code === "invalid_invite") { + return translate("cloud.orgManagement.errors.inviteInvalid"); + } + if (error.code === "session_expired") { + return translate("cloud.sessionExpired"); + } + return translate("cloud.orgPanel.loadError"); + } + return cloudManagementErrorMessage(error, translate); +} + +/** First-use organization boundary for an authoritatively empty Web roster. */ +export function WebOrganizationOnboarding({ + refreshError, + onRetry, +}: { + refreshError?: string | null; + onRetry?: () => void; +}) { + const { t } = useTranslation("navigation"); + const { createOrganization, joinOrganization } = + useCloudOrgMembershipActions(); + const [mode, setMode] = useState("create"); + const [value, setValue] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const mountedRef = useRef(true); + const submittingRef = useRef(false); + + useEffect( + () => () => { + mountedRef.current = false; + }, + [] + ); + + const trimmedValue = value.trim(); + const inputLabel = + mode === "create" + ? t("collaboration.orgName") + : t("collaboration.inviteCode"); + const submitLabel = + mode === "create" + ? t("web.sessionsPage.createOrganization") + : t("web.sessionsPage.joinOrganization"); + const placeholder = + mode === "create" + ? t("web.sessionsPage.organizationNamePlaceholder") + : t("web.sessionsPage.invitePlaceholder"); + + const modeOptions = useMemo( + () => + [ + { + value: "create" as const, + label: t("web.sessionsPage.createOrganization"), + }, + { + value: "join" as const, + label: t("web.sessionsPage.joinOrganization"), + }, + ] satisfies Array<{ value: OrganizationMode; label: string }>, + [t] + ); + + const selectMode = useCallback((nextMode: OrganizationMode) => { + setMode(nextMode); + setValue(""); + setError(null); + }, []); + + const submit = useCallback(async () => { + if (!trimmedValue || submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + setError(null); + try { + if (mode === "create") { + await createOrganization(trimmedValue); + Message.success(t("cloud.orgManagement.create.createdToast")); + } else { + const joined = await joinOrganization(trimmedValue); + Message.success( + t("cloud.orgManagement.join.joinedToast", { org: joined.name }) + ); + } + } catch (caught) { + if (mountedRef.current) { + setError(membershipActionErrorMessage(caught, t)); + } + } finally { + submittingRef.current = false; + if (mountedRef.current) setSubmitting(false); + } + }, [createOrganization, joinOrganization, mode, t, trimmedValue]); + + return ( +
+
+ +
+

+ {t("web.sessionsPage.organizationSetupTitle")} +

+

+ {t("web.sessionsPage.organizationSetupHint")} +

+
+ +
+ {modeOptions.map((option) => ( + + ))} +
+ + {refreshError ? ( +
+ {refreshError} + {onRetry ? ( + + ) : null} +
+ ) : null} + + { + event.preventDefault(); + void submit(); + }} + > + + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+ ); +} 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 ( + <> + + +