diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts index 2968f178b..7fea222a2 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts @@ -47,6 +47,25 @@ function status( }, capabilitiesLoading: false, lastSync: { lastPassAtMs: null, lastSuccessAtMs: null }, + coverage: { + repos: [ + { + repoScope: "github.com/acme/alpha", + syncable: 6, + synced: 2, + percent: 33, + }, + { + repoScope: "github.com/acme/beta", + syncable: 2, + synced: 2, + percent: 100, + }, + ], + syncable: 8, + synced: 4, + percent: 50, + }, entries: [], running: false, runSucceeded: false, @@ -189,6 +208,130 @@ describe("CloudOrgSyncSection connection block", () => { }); }); +describe("CloudOrgSyncSection coverage block", () => { + it("shows a loading row instead of a false empty state during the full scan", () => { + const root = renderSection({ + coverageLoading: true, + }); + + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-loading"]') + ?.textContent + ).toContain("cloud.orgPanel.loading"); + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-empty"]') + ).toBeNull(); + expect(root.textContent).not.toContain( + "cloud.orgPanel.sync.coverageSummary" + ); + }); + + it("reports an unavailable aggregate instead of a false empty state", () => { + const root = renderSection({ + coverageUnavailable: true, + coverage: { repos: [], syncable: 0, synced: 0, percent: null }, + }); + + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-unavailable"]') + ?.textContent + ).toContain("cloud.orgPanel.loadError"); + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-empty"]') + ).toBeNull(); + }); + + it("renders exactly one row per org repo scope, in order", () => { + const root = renderSection(); + + const rows = root.querySelectorAll( + '[data-testid="cloud-org-sync-coverage-repo"]' + ); + expect(rows).toHaveLength(2); + expect(rows[0]?.textContent).toContain("github.com/acme/alpha"); + expect(rows[1]?.textContent).toContain("github.com/acme/beta"); + + const counts = root.querySelectorAll( + '[data-testid="cloud-org-sync-coverage-repo-count"]' + ); + expect(counts[0]?.textContent).toBe("2/6"); + expect(counts[1]?.textContent).toBe("2/2"); + + const percents = root.querySelectorAll( + '[data-testid="cloud-org-sync-coverage-repo-percent"]' + ); + expect(percents[0]?.textContent).toBe("33%"); + expect(percents[1]?.textContent).toBe("100%"); + + const bars = root.querySelectorAll('[role="progressbar"]'); + expect(bars).toHaveLength(2); + expect(bars[0]?.getAttribute("aria-valuenow")).toBe("33"); + expect(bars[1]?.getAttribute("aria-valuenow")).toBe("100"); + }); + + it("shows an empty state when no scoped repo has sessions", () => { + const root = renderSection({ + coverage: { repos: [], syncable: 0, synced: 0, percent: null }, + }); + + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-empty"]') + ?.textContent + ).toContain("cloud.orgPanel.sync.coverageEmpty"); + expect( + root.querySelector('[data-testid="cloud-org-sync-coverage-repo"]') + ).toBeNull(); + expect(root.querySelector('[role="progressbar"]')).toBeNull(); + }); + + it("keeps a sliver of bar visible for a non-zero but tiny percentage", () => { + const root = renderSection({ + coverage: { + repos: [ + { + repoScope: "github.com/acme/alpha", + syncable: 400, + synced: 1, + percent: 0, + }, + ], + syncable: 400, + synced: 1, + percent: 0, + }, + }); + + // Rounds to 0% but IS synced — a fully empty bar would read as "none". + const fill = root + .querySelector('[role="progressbar"]') + ?.querySelector("div"); + expect(fill?.getAttribute("style")).toContain("width:2%"); + }); + + it("leaves the bar truly empty when nothing in the repo is synced", () => { + const root = renderSection({ + coverage: { + repos: [ + { + repoScope: "github.com/acme/alpha", + syncable: 9, + synced: 0, + percent: 0, + }, + ], + syncable: 9, + synced: 0, + percent: 0, + }, + }); + + const fill = root + .querySelector('[role="progressbar"]') + ?.querySelector("div"); + expect(fill?.getAttribute("style")).toContain("width:0%"); + }); +}); + describe("CloudOrgSyncSection last-sync block", () => { it("shows the never-synced empty state", () => { const root = renderSection(); @@ -263,6 +406,22 @@ describe("CloudOrgSyncSection manual sync", () => { ).toBeNull(); }); + it("places the outcome note to the LEFT of the primary button", () => { + // The control cell right-aligns, so DOM order is what puts the note on + // the left and keeps the button pinned to the edge. + for (const [testId, root] of [ + ["cloud-org-sync-run-success", renderSection({ runSucceeded: true })], + ["cloud-org-sync-run-error", renderSection({ runError: "network down" })], + ] as const) { + const note = root.querySelector(`[data-testid="${testId}"]`); + const button = root.querySelector('[data-testid="cloud-org-sync-run"]'); + if (!note || !button) throw new Error(`missing ${testId} or run button`); + expect(note.parentElement).toBe(button.parentElement); + const siblings = Array.from(note.parentElement?.children ?? []); + expect(siblings.indexOf(note)).toBeLessThan(siblings.indexOf(button)); + } + }); + it("invokes runSync on click without throwing", async () => { const runSync = vi.fn(); const root = createSmokeRoot(); diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx index 995d35d6e..b8186b0a6 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx @@ -1,7 +1,7 @@ /** * Sync tab — last-sync clock plus manual trigger (leading, since that is what - * a user opens this tab for), then per-org connection health, then the local - * sync journal ("bug logs"). + * a user opens this tab for), then per-repo session coverage for the org, then + * per-org connection health, then the local sync journal ("bug logs"). * * Presentational by design: every value arrives through `status` * (`useCloudOrgSyncStatus`), matching how `CloudOrgSettingsSection` consumes @@ -17,6 +17,7 @@ import React, { useCallback, useMemo } from "react"; import Button from "@src/components/Button"; import type { CloudCapabilities } from "@src/features/Org2Cloud/org2CloudCapabilities"; +import type { RepoSyncCoverage } from "@src/features/Org2Cloud/org2CloudSyncCoverage"; import type { SyncJournalEntry } from "@src/features/Org2Cloud/org2CloudSyncJournal"; import { useCopyCheck } from "@src/hooks/ui/useCopyCheck"; import { @@ -52,6 +53,64 @@ const LEVEL_LABEL_KEYS: Record = { error: "cloud.orgPanel.sync.levelError", }; +/** + * Coverage bar fill. Floored to a visible sliver whenever ANYTHING is synced: + * a large repo makes the first few sessions round to 0%, and an empty bar + * beside a non-zero synced count reads as "nothing published". + */ +function coverageBarWidth(row: RepoSyncCoverage): string { + return `${row.synced > 0 ? Math.max(row.percent, 2) : 0}%`; +} + +interface CoverageRowProps { + t: TFunction<"navigation">; + row: RepoSyncCoverage; +} + +/** One repo, one row: the org scope left, synced/total + meter + % right. */ +function CoverageRow({ t, row }: CoverageRowProps) { + return ( + + {row.repoScope} + + } + truncateLabel + > +
+ + {`${row.synced.toLocaleString()}/${row.syncable.toLocaleString()}`} + +
+
+
+ + {`${row.percent}%`} + +
+ + ); +} + /** Expiry is a wall-clock comparison, so it stays out of the render body. */ function isExpired(atMs: number | null): boolean { return atMs !== null && atMs <= Date.now(); @@ -118,6 +177,20 @@ export function CloudOrgSyncSection({ t, status }: CloudOrgSyncSectionProps) { const tokenExpiresAtMs = status.tokenExpiresAtMs; const tokenExpired = isExpired(tokenExpiresAtMs); const lastSuccessAtMs = status.lastSync.lastSuccessAtMs; + const coverage = status.coverage; + const coverageTitle = + status.coverageLoading || + status.coverageUnavailable || + coverage.percent === null + ? t("cloud.orgPanel.sync.coverageTitle") + : `${t("cloud.orgPanel.sync.coverageTitle")} · ${t( + "cloud.orgPanel.sync.coverageSummary", + { + synced: coverage.synced.toLocaleString(), + syncable: coverage.syncable.toLocaleString(), + percent: coverage.percent, + } + )}`; return ( <> @@ -158,20 +231,10 @@ export function CloudOrgSyncSection({ t, status }: CloudOrgSyncSectionProps) { label={t("cloud.orgPanel.sync.manualLabel")} align="start" > + {/* Outcome note LEADS the button: the row is right-aligned, so the + button stays pinned to the edge and the note grows leftward instead + of pushing it around as the text changes. */}
- {status.runError ? ( ) : null} +
+ {/* Totals ride in the title so the body stays strictly one row per + repo — the whole-device number is context, not a competing row. */} + + {status.coverageLoading ? ( + + ) : status.coverageUnavailable ? ( + + ) : coverage.repos.length === 0 ? ( + + ) : ( + coverage.repos.map((row) => ( + + )) + )} + + ; +} + +export default CloudOrgSyncTab; diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx index 90722e932..f9c35cfc0 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/index.tsx @@ -32,7 +32,7 @@ import { WORK_MANAGEMENT_SECTION } from "@src/store/workstation"; import CloudOrgPanelHeader from "./CloudOrgPanelHeader"; import CloudOrgRepoScopesSection from "./CloudOrgRepoScopesSection"; import CloudOrgSettingsSection from "./CloudOrgSettingsSection"; -import CloudOrgSyncSection from "./CloudOrgSyncSection"; +import CloudOrgSyncTab from "./CloudOrgSyncTab"; import { CloudInvitesCard, CloudMembersSection } from "./ManagementSections"; import { CLOUD_ORG_MANAGEMENT_TAB, @@ -40,7 +40,6 @@ import { } from "./cloudOrgPanelTypes"; import { useCloudOrgManagement } from "./useCloudOrgManagement"; import { useCloudOrgPanelState } from "./useCloudOrgPanelState"; -import { useCloudOrgSyncStatus } from "./useCloudOrgSyncStatus"; import { useOrgOfflineSync } from "./useOrgOfflineSync"; import { useOrgRuntimeTelemetry } from "./useOrgRuntimeTelemetry"; @@ -67,7 +66,6 @@ export const CloudOrgPanelView: React.FC = ({ const isOwner = org?.role === "owner"; const panelState = useCloudOrgPanelState(orgId); const runtimeSharing = useOrgRuntimeTelemetry(orgId); - const syncStatus = useCloudOrgSyncStatus(orgId); const offlineSync = useOrgOfflineSync(orgId); const management = useCloudOrgManagement({ orgId, @@ -145,7 +143,7 @@ export const CloudOrgPanelView: React.FC = ({ ) : null} {activeTab === CLOUD_ORG_MANAGEMENT_TAB.SYNC ? ( - + ) : null} {activeTab === CLOUD_ORG_MANAGEMENT_TAB.MEMBERS ? ( diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts index 2e341df10..4f248395c 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts @@ -1,18 +1,41 @@ // @vitest-environment jsdom +import { getDefaultStore } from "jotai"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + org2CloudAccessSettingsAtom, + org2CloudSharingFloorAtom, +} from "@src/features/Org2Cloud/org2CloudAccessSettings"; +import { + org2CloudPushCursorsAtom, + org2CloudPushedMetadataAtom, + org2CloudRepoScopesAtom, +} from "@src/features/Org2Cloud/org2CloudSyncAtoms"; import { resetSyncJournalForTests } from "@src/features/Org2Cloud/org2CloudSyncJournal"; +import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; +import type { Session } from "@src/store/session/sessionAtom/types"; import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; import type { CloudOrgSyncStatus } from "./useCloudOrgSyncStatus"; -import { useCloudOrgSyncStatus } from "./useCloudOrgSyncStatus"; +import { + loadCompleteCoverageRoster, + useCloudOrgSyncStatus, +} from "./useCloudOrgSyncStatus"; const mocks = vi.hoisted(() => ({ runSyncPassAndWaitForDrain: vi.fn<() => Promise>(), schemaVersion: vi.fn<() => Promise>(), getCloudCapabilities: vi.fn(), endpointForOrg: vi.fn(), + sessionAggregateList: vi.fn(), + toFrontendSessions: vi.fn((sessions: unknown[]) => sessions), +})); + +vi.mock("@src/api/tauri/session", async (importOriginal) => ({ + ...(await importOriginal()), + sessionAggregateList: mocks.sessionAggregateList, + toFrontendSessions: mocks.toFrontendSessions, })); vi.mock("@src/features/Org2Cloud/org2CloudSyncEngine", () => ({ @@ -78,13 +101,87 @@ beforeEach(() => { anonKey: "super-secret-anon-key", isOfficial: true, }); + mocks.sessionAggregateList.mockReset(); + mocks.sessionAggregateList.mockImplementation(async () => ({ + sessions: getDefaultStore().get(sessionsAtom), + })); + mocks.toFrontendSessions.mockClear(); + getDefaultStore().set(org2CloudSharingFloorAtom, { + "org-1": "metadata_only", + }); }); afterEach(() => { resetSyncJournalForTests(); + const store = getDefaultStore(); + store.set(sessionsAtom, []); + store.set(org2CloudPushedMetadataAtom, {}); + store.set(org2CloudPushCursorsAtom, {}); + store.set(org2CloudRepoScopesAtom, {}); + store.set(org2CloudAccessSettingsAtom, {}); + store.set(org2CloudSharingFloorAtom, {}); }); +/** + * `repoPath` is written as a remote-style scope key on purpose: the resolver + * short-circuits those to themselves, so grouping is synchronous here instead + * of waiting on a real git-remote lookup. + */ +function localSession( + session_id: string, + overrides: Partial = {} +): Session { + return { + session_id, + orgId: "cloud:org-1", + ...overrides, + } as Session; +} + describe("useCloudOrgSyncStatus", () => { + it("reads every bounded aggregate page instead of the UI roster page", async () => { + const all = Array.from({ length: 201 }, (_, index) => + localSession(`session-${index}`) + ); + const loadPage = vi.fn(async (filter: { offset?: number }) => ({ + sessions: filter.offset === 0 ? all.slice(0, 200) : all.slice(200), + })); + + const loaded = await loadCompleteCoverageRoster( + { + includeExternalHistory: true, + disabledExternalHistorySources: ["cursor"], + }, + loadPage as never + ); + + expect(loaded).toHaveLength(201); + expect(loadPage).toHaveBeenCalledTimes(2); + expect(loadPage.mock.calls[0]?.[0]).toMatchObject({ + limit: 200, + offset: 0, + includeExternalHistory: true, + disabledExternalHistorySources: ["cursor"], + }); + expect(loadPage.mock.calls[1]?.[0]).toMatchObject({ + limit: 200, + offset: 200, + }); + }); + + it("marks coverage unavailable when the authoritative roster read fails", async () => { + mocks.sessionAggregateList.mockRejectedValueOnce(new Error("offline")); + const probe = mountStatus(); + try { + await probe.mount(); + expect(probe.read().coverageLoading).toBe(false); + expect(probe.read().coverageUnavailable).toBe(true); + expect(probe.read().coverage.syncable).toBe(0); + } finally { + await probe.root.unmount(); + } + }); + it("exposes the backend kind but never the endpoint URL or the anon key", async () => { const probe = mountStatus(); try { @@ -158,6 +255,127 @@ describe("useCloudOrgSyncStatus", () => { } }); + it("groups coverage by the org's repo scopes, one row per repo", async () => { + const store = getDefaultStore(); + store.set(org2CloudRepoScopesAtom, { + "org-1": ["github.com/acme/alpha", "github.com/acme/beta"], + }); + store.set(sessionsAtom, [ + localSession("s1", { repoPath: "github.com/acme/alpha" }), + localSession("s2", { repoPath: "github.com/acme/alpha" }), + localSession("s3", { repoPath: "github.com/acme/alpha" }), + localSession("s4", { repoPath: "github.com/acme/beta" }), + // Excluded from every denominator: a subagent transcript, a spawned + // child, and a teammate copy pulled down from an org. + localSession("s1:subagent:0", { repoPath: "github.com/acme/alpha" }), + localSession("s5", { + repoPath: "github.com/acme/alpha", + parentSessionId: "s1", + }), + localSession("s6", { + repoPath: "github.com/acme/alpha", + importedFrom: { orgId: "org-1" } as never, + }), + ]); + store.set(org2CloudPushedMetadataAtom, { + "org-1:s1": true, + // Another org's marker must not lift this org's number. + "org-2:s3": true, + }); + // A segments cursor is push evidence too, on the full_replay rung. + store.set(org2CloudPushCursorsAtom, { + "org-1:s2": { + orgId: "org-1", + sessionId: "s2", + epoch: 1, + frozenSeq: 0, + pushedCount: 3, + frozenEventCount: 0, + frozenChainHash: "", + tailHash: null, + }, + }); + + const probe = mountStatus(); + try { + await probe.mount(); + expect(probe.read().coverage).toEqual({ + repos: [ + { + repoScope: "github.com/acme/alpha", + syncable: 3, + synced: 2, + percent: 67, + }, + { + repoScope: "github.com/acme/beta", + syncable: 1, + synced: 0, + percent: 0, + }, + ], + syncable: 4, + synced: 2, + percent: 50, + }); + } finally { + await probe.root.unmount(); + } + }); + + it("omits repos the org has not scoped, and their sessions", async () => { + const store = getDefaultStore(); + store.set(org2CloudRepoScopesAtom, { "org-1": ["github.com/acme/alpha"] }); + store.set(sessionsAtom, [ + localSession("s1", { repoPath: "github.com/acme/alpha" }), + // Neither of these can ever reach this org — no row, and no drag on the + // headline percentage. + localSession("s2", { repoPath: "github.com/me/side-project" }), + localSession("s3"), + ]); + store.set(org2CloudPushedMetadataAtom, { "org-1:s1": true }); + + const probe = mountStatus(); + try { + await probe.mount(); + expect(probe.read().coverage).toEqual({ + repos: [ + { + repoScope: "github.com/acme/alpha", + syncable: 1, + synced: 1, + percent: 100, + }, + ], + syncable: 1, + synced: 1, + percent: 100, + }); + } finally { + await probe.root.unmount(); + } + }); + + it("reports an empty coverage state when the org has no repo scopes", async () => { + const store = getDefaultStore(); + store.set(sessionsAtom, [ + localSession("s1", { repoPath: "github.com/acme/alpha" }), + ]); + + const probe = mountStatus(); + try { + await probe.mount(); + expect(probe.read().coverage).toEqual({ + repos: [], + syncable: 0, + synced: 0, + percent: null, + }); + } finally { + await probe.root.unmount(); + } + }); + it("runs the engine's drain-waiting pass and reports success", async () => { const probe = mountStatus(); try { diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts index 3cc6b32be..f8a50a769 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts @@ -15,15 +15,36 @@ * render its ORIGIN. The anon key, access token, and refresh token never leave * this module. */ -import { useAtomValue } from "jotai"; +import { useAtomValue, useStore } from "jotai"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type SessionFilter, + sessionAggregateList, + toFrontendSessions, +} from "@src/api/tauri/session"; import { ORG2_CLOUD_EXPECTED_SCHEMA_VERSION } from "@src/features/Org2Cloud/config"; +import { + org2CloudAccessSettingsAtom, + org2CloudSharingFloorAtom, +} from "@src/features/Org2Cloud/org2CloudAccessSettings"; import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; import type { CloudCapabilities } from "@src/features/Org2Cloud/org2CloudCapabilities"; import { getCloudCapabilities } from "@src/features/Org2Cloud/org2CloudCapabilities"; import { schemaVersion } from "@src/features/Org2Cloud/org2CloudClient"; import { endpointForOrg } from "@src/features/Org2Cloud/org2CloudOrgEndpointRouter"; +import { + org2CloudPushCursorsAtom, + org2CloudPushedMetadataAtom, + org2CloudRepoScopesAtom, +} from "@src/features/Org2Cloud/org2CloudSyncAtoms"; +import { + type SessionSyncCoverage, + computeSessionSyncCoverage, + createOrgRepoScopeResolver, + createOrgSyncCoverageEligibilityResolver, + pushedSessionIdsForOrg, +} from "@src/features/Org2Cloud/org2CloudSyncCoverage"; import { org2CloudSyncEngine } from "@src/features/Org2Cloud/org2CloudSyncEngine"; import { type SyncJournalEntry, @@ -33,6 +54,66 @@ import { useLastSyncState, useSyncJournal, } from "@src/features/Org2Cloud/org2CloudSyncJournal"; +import { useShareableScopeKeyVersion } from "@src/features/TeamCollaboration/repoScopeResolver"; +import { sessionOrgTagsAtom } from "@src/features/TeamCollaboration/sessionOrgTagsAtom"; +import { + dataSourceConfigAtom, + externalSessionsEnabledAtom, +} from "@src/store/session/dataSourceConfigAtom"; +import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; +import type { Session } from "@src/store/session/sessionAtom/types"; + +const COVERAGE_ROSTER_PAGE_SIZE = 200; + +export interface CompleteCoverageRosterOptions { + includeExternalHistory: boolean; + disabledExternalHistorySources: string[]; +} + +type CoverageRosterPageLoader = ( + filter: SessionFilter +) => ReturnType; + +/** Bounded-page scan of the authoritative session aggregate. */ +export async function loadCompleteCoverageRoster( + options: CompleteCoverageRosterOptions, + loadPage: CoverageRosterPageLoader = sessionAggregateList +): Promise { + const byId = new Map(); + let offset = 0; + for (;;) { + const response = await loadPage({ + limit: COVERAGE_ROSTER_PAGE_SIZE, + offset, + includeExternalHistory: options.includeExternalHistory, + disabledExternalHistorySources: + options.includeExternalHistory && + options.disabledExternalHistorySources.length > 0 + ? options.disabledExternalHistorySources + : undefined, + sortBy: "updated_at", + sortOrder: "desc", + }); + const page = toFrontendSessions(response.sessions); + for (const session of page) byId.set(session.session_id, session); + if (response.sessions.length < COVERAGE_ROSTER_PAGE_SIZE) break; + offset += response.sessions.length; + } + return [...byId.values()]; +} + +function mergeCoverageRoster( + authoritative: readonly Session[], + live: readonly Session[] +): Session[] { + const byId = new Map( + authoritative.map((session) => [session.session_id, session]) + ); + // Live rows preserve frontend-only provenance fields for sessions present in + // the active store, while the aggregate supplies every older/paged-out row. + for (const session of live) byId.set(session.session_id, session); + return [...byId.values()]; +} export type SchemaProbeStatus = | "checking" @@ -53,6 +134,12 @@ export interface CloudOrgSyncStatus { capabilities: CloudCapabilities | null; capabilitiesLoading: boolean; lastSync: SyncJournalLastSyncState; + /** Per-repo publish coverage across THIS org's repo scopes. */ + coverage: SessionSyncCoverage; + /** The complete aggregate is still being read; don't render a false 0%. */ + coverageLoading?: boolean; + /** The authoritative roster read failed; a visibility/manual-sync retries. */ + coverageUnavailable?: boolean; entries: readonly SyncJournalEntry[]; running: boolean; runSucceeded: boolean; @@ -63,6 +150,7 @@ export interface CloudOrgSyncStatus { } export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { + const store = useStore(); const auth = useAtomValue(org2CloudAuthAtom); const entries = useSyncJournal(); const lastSync = useLastSyncState(); @@ -70,6 +158,119 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { const accessToken = auth?.accessToken ?? null; const endpoint = useMemo(() => endpointForOrg(orgId), [orgId]); + const dataSourceConfig = useAtomValue(dataSourceConfigAtom); + const externalSessionsEnabled = useAtomValue(externalSessionsEnabledAtom); + const [coverageSessions, setCoverageSessions] = useState([]); + const [coverageLoading, setCoverageLoading] = useState(true); + const [coverageUnavailable, setCoverageUnavailable] = useState(false); + const coverageRosterGenerationRef = useRef(0); + const coverageRosterInFlightRef = useRef<{ + key: string; + request: Promise; + } | null>(null); + const refreshCoverageRoster = useCallback(async () => { + if ( + typeof document !== "undefined" && + document.visibilityState === "hidden" + ) { + return; + } + const generation = ++coverageRosterGenerationRef.current; + setCoverageLoading(true); + setCoverageUnavailable(false); + const disabledExternalHistorySources = Object.entries(dataSourceConfig) + .filter(([, config]) => config.enabled === false) + .map(([sourceId]) => sourceId) + .sort(); + const requestKey = JSON.stringify([ + externalSessionsEnabled, + disabledExternalHistorySources, + ]); + let load = coverageRosterInFlightRef.current; + if (!load || load.key !== requestKey) { + load = { + key: requestKey, + request: loadCompleteCoverageRoster({ + includeExternalHistory: externalSessionsEnabled, + disabledExternalHistorySources, + }), + }; + coverageRosterInFlightRef.current = load; + } + try { + const authoritative = await load.request; + if (generation !== coverageRosterGenerationRef.current) return; + setCoverageSessions( + mergeCoverageRoster(authoritative, store.get(sessionsAtom)) + ); + setCoverageLoading(false); + } catch { + if (generation !== coverageRosterGenerationRef.current) return; + // Never present a paginated subset as device-wide coverage. Visibility + // changes and manual sync both retry the authoritative read. + setCoverageLoading(false); + setCoverageUnavailable(true); + } finally { + if (coverageRosterInFlightRef.current?.request === load.request) { + coverageRosterInFlightRef.current = null; + } + } + }, [dataSourceConfig, externalSessionsEnabled, store]); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void refreshCoverageRoster(); + } + }; + void refreshCoverageRoster(); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + coverageRosterGenerationRef.current += 1; + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [refreshCoverageRoster]); + + const pushedMetadata = useAtomValue(org2CloudPushedMetadataAtom); + const pushCursors = useAtomValue(org2CloudPushCursorsAtom); + const tags = useAtomValue(sessionOrgTagsAtom); + const accessByOrg = useAtomValue(org2CloudAccessSettingsAtom); + const floorByOrg = useAtomValue(org2CloudSharingFloorAtom); + // The org's OWN repo scopes are the row set: the panel reports on the repos + // this org can receive, not every repo on the device. The mirror is the same + // one the push pass matches against, so the rows and the engine agree. + const orgScopes = useAtomValue(org2CloudRepoScopesAtom)[orgId]; + // Scope matching reads the shareable-scope-key and repo-identity caches, + // which fill in asynchronously (both bump this one version). The + // subscription is what turns a cold cache into rows. + const scopeKeyVersion = useShareableScopeKeyVersion(); + const coverage = useMemo(() => { + // The version is an invalidation signal, not an input: reading it here is + // what makes it an honest dependency rather than a suppressed lint rule. + void scopeKeyVersion; + return computeSessionSyncCoverage( + coverageSessions, + pushedSessionIdsForOrg(orgId, pushedMetadata, pushCursors), + createOrgRepoScopeResolver(orgScopes), + createOrgSyncCoverageEligibilityResolver({ + orgId, + tags, + accessByOrg, + floorByOrg, + }) + ); + }, [ + orgId, + orgScopes, + pushCursors, + pushedMetadata, + scopeKeyVersion, + accessByOrg, + coverageSessions, + floorByOrg, + tags, + ]); + const [schemaStatus, setSchemaStatus] = useState("checking"); const [backendSchemaVersion, setBackendSchemaVersion] = useState< @@ -155,6 +356,8 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { try { await org2CloudSyncEngine.runSyncPassAndWaitForDrain(); if (!mountedRef.current) return; + await refreshCoverageRoster(); + if (!mountedRef.current) return; setRunSucceeded(true); } catch (error) { if (!mountedRef.current) return; @@ -163,7 +366,7 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { if (mountedRef.current) setRunning(false); } })(); - }, [running]); + }, [refreshCoverageRoster, running]); return { // The endpoint URL is deliberately NOT exposed: the panel only reports @@ -181,6 +384,9 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { capabilities, capabilitiesLoading, lastSync, + coverage, + coverageLoading, + coverageUnavailable, entries, running, runSucceeded, diff --git a/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts b/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts index 0ce1127de..202607c58 100644 --- a/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts +++ b/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts @@ -85,7 +85,9 @@ export function buildCloudSessionMetadata( } /** True for local sessions that may ever be pushed to the cloud. */ -export function isCloudPushCandidate(session: Session): boolean { +export function isCloudPushCandidate( + session: Pick +): boolean { // Imported teammate copies must never round-trip under the local user. // The user's own external history has no importedFrom and remains shareable. return !session.importedFrom; diff --git a/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts b/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts new file mode 100644 index 000000000..6895bc56c --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; + +import { + type RepoScopeResolver, + type SyncCoverageSession, + computeSessionSyncCoverage, + createOrgSyncCoverageEligibilityResolver, + isSyncCoverageSession, + pushedSessionIdsForOrg, +} from "./org2CloudSyncCoverage"; + +function session( + session_id: string, + overrides: Partial = {} +): SyncCoverageSession { + return { session_id, ...overrides }; +} + +const IMPORTED_FROM = { + orgId: "org-a", + sourceSessionId: "remote-1", + ownerMemberId: "member-1", + epoch: 1, + seq: 1, + count: 1, +} as SyncCoverageSession["importedFrom"]; + +/** + * Stand-in for `createOrgRepoScopeResolver`: matches `repoPath` against the + * given scopes so grouping is testable without the async scope-key caches. + * Same three-way contract — scope string / null (out of scope) / undefined. + */ +function scopedBy(...scopes: string[]): RepoScopeResolver { + return (s) => { + if (s.repoPath === undefined) return null; + if (s.repoPath === "pending") return undefined; + return scopes.includes(s.repoPath) ? s.repoPath : null; + }; +} + +describe("isSyncCoverageSession", () => { + it("counts an ordinary local session", () => { + expect(isSyncCoverageSession(session("s1"))).toBe(true); + }); + + it("drops a subagent session carrying a parent id", () => { + expect( + isSyncCoverageSession(session("s1", { parentSessionId: "root" })) + ).toBe(false); + }); + + it("drops a subagent session identified by its id segment", () => { + expect(isSyncCoverageSession(session("root:subagent:1"))).toBe(false); + }); + + it("drops an Agent-Team worker session", () => { + expect(isSyncCoverageSession(session("s1", { orgMemberId: "m1" }))).toBe( + false + ); + }); + + it("keeps an Agent-Team ROOT session (member id plus agent org)", () => { + expect( + isSyncCoverageSession( + session("s1", { orgMemberId: "m1", agentOrgId: "ao1" }) + ) + ).toBe(true); + }); + + it("drops a teammate copy imported from an org", () => { + expect( + isSyncCoverageSession(session("s1", { importedFrom: IMPORTED_FROM })) + ).toBe(false); + }); +}); + +describe("createOrgSyncCoverageEligibilityResolver", () => { + it("excludes an ordinary Personal session even when its repo is scoped", () => { + const eligible = createOrgSyncCoverageEligibilityResolver({ + orgId: "org-a", + tags: {}, + accessByOrg: {}, + floorByOrg: { "org-a": "metadata_only" }, + }); + + expect(eligible(session("personal"))).toBe(false); + }); + + it("admits an org-owned session at the org sharing floor", () => { + const eligible = createOrgSyncCoverageEligibilityResolver({ + orgId: "org-a", + tags: {}, + accessByOrg: {}, + floorByOrg: { "org-a": "metadata_only" }, + }); + + expect(eligible(session("owned", { orgId: "cloud:org-a" }))).toBe(true); + }); + + it("requires effective access for an admitted but unfloored session", () => { + const eligible = createOrgSyncCoverageEligibilityResolver({ + orgId: "org-a", + tags: {}, + accessByOrg: {}, + floorByOrg: {}, + }); + + expect(eligible(session("owned", { orgId: "cloud:org-a" }))).toBe(false); + }); + + it("treats an explicit tag as admission and metadata-only access", () => { + const eligible = createOrgSyncCoverageEligibilityResolver({ + orgId: "org-a", + tags: { tagged: ["cloud:org-a"] }, + accessByOrg: {}, + floorByOrg: {}, + }); + + expect(eligible(session("tagged"))).toBe(true); + }); + + it("does not admit an untagged fork to a different org", () => { + const eligible = createOrgSyncCoverageEligibilityResolver({ + orgId: "org-a", + tags: {}, + accessByOrg: {}, + floorByOrg: { "org-a": "metadata_only" }, + }); + + expect( + eligible( + session("fork", { + orgId: "cloud:org-a", + forkedFrom: { orgId: "org-b" } as never, + }) + ) + ).toBe(false); + }); +}); + +describe("pushedSessionIdsForOrg", () => { + it("keeps only this org's keys and strips the prefix", () => { + const ids = pushedSessionIdsForOrg( + "org-a", + { "org-a:s1": true, "org-b:s2": true }, + { "org-a:s3": {} } + ); + expect([...ids].sort()).toEqual(["s1", "s3"]); + }); + + it("cuts only the org prefix when the session id contains a colon", () => { + const ids = pushedSessionIdsForOrg( + "org-a", + { "org-a:root:subagent:1": true }, + {} + ); + expect([...ids]).toEqual(["root:subagent:1"]); + }); + + it("does not treat a longer org id as a prefix match", () => { + const ids = pushedSessionIdsForOrg("org-a", { "org-a2:s1": true }, {}); + expect(ids.size).toBe(0); + }); +}); + +describe("computeSessionSyncCoverage", () => { + it("reports no repos and a null percent for an empty roster", () => { + expect(computeSessionSyncCoverage([], new Set(), scopedBy())).toEqual({ + repos: [], + syncable: 0, + synced: 0, + percent: null, + }); + }); + + it("gives each scoped repo its own row with its own percentage", () => { + const coverage = computeSessionSyncCoverage( + [ + session("s1", { repoPath: "alpha" }), + session("s2", { repoPath: "alpha" }), + session("s3", { repoPath: "alpha" }), + session("s4", { repoPath: "beta" }), + ], + new Set(["s1", "s2"]), + scopedBy("alpha", "beta") + ); + + expect(coverage.repos).toEqual([ + { repoScope: "alpha", syncable: 3, synced: 2, percent: 67 }, + { repoScope: "beta", syncable: 1, synced: 0, percent: 0 }, + ]); + expect(coverage.syncable).toBe(4); + expect(coverage.synced).toBe(2); + expect(coverage.percent).toBe(50); + }); + + it("lists ONLY the org's scoped repos, never the rest of the device", () => { + const coverage = computeSessionSyncCoverage( + [ + session("s1", { repoPath: "alpha" }), + // Out of scope, and a session with no repo at all: neither is work + // this org can receive, so neither becomes a row. + session("s2", { repoPath: "personal-side-project" }), + session("s3"), + ], + new Set(["s1"]), + scopedBy("alpha") + ); + + expect(coverage.repos).toEqual([ + { repoScope: "alpha", syncable: 1, synced: 1, percent: 100 }, + ]); + // The out-of-scope sessions must not drag the headline down either. + expect(coverage.syncable).toBe(1); + expect(coverage.percent).toBe(100); + }); + + it("reports nothing when the org has no repo scopes configured", () => { + const coverage = computeSessionSyncCoverage( + [session("s1", { repoPath: "alpha" }), session("s2")], + new Set(["s1"]), + scopedBy() + ); + + expect(coverage).toEqual({ + repos: [], + syncable: 0, + synced: 0, + percent: null, + }); + }); + + it("excludes subagent and imported sessions from every row", () => { + const coverage = computeSessionSyncCoverage( + [ + session("s1", { repoPath: "alpha" }), + session("s2", { repoPath: "alpha", parentSessionId: "s1" }), + session("s1:subagent:0", { repoPath: "alpha" }), + session("s3", { repoPath: "alpha", importedFrom: IMPORTED_FROM }), + ], + new Set(["s1", "s2", "s1:subagent:0", "s3"]), + scopedBy("alpha") + ); + + expect(coverage.repos).toEqual([ + { repoScope: "alpha", syncable: 1, synced: 1, percent: 100 }, + ]); + expect(coverage.syncable).toBe(1); + }); + + it("skips sessions whose scope lookup is still in flight", () => { + const coverage = computeSessionSyncCoverage( + [ + session("s1", { repoPath: "alpha" }), + session("s2", { repoPath: "pending" }), + ], + new Set(), + scopedBy("alpha", "pending") + ); + + // s2 contributes to nothing — not a row, not the totals — until the + // resolver answers for it. + expect(coverage.repos).toEqual([ + { repoScope: "alpha", syncable: 1, synced: 0, percent: 0 }, + ]); + expect(coverage.syncable).toBe(1); + }); + + it("orders by size, then scope string", () => { + const coverage = computeSessionSyncCoverage( + [ + session("s1", { repoPath: "zeta" }), + session("s2", { repoPath: "alpha" }), + session("s3", { repoPath: "big" }), + session("s4", { repoPath: "big" }), + ], + new Set(), + scopedBy("alpha", "big", "zeta") + ); + + expect(coverage.repos.map((row) => row.repoScope)).toEqual([ + "big", + "alpha", + "zeta", + ]); + }); + + it("ignores push markers for sessions no longer in the roster", () => { + const coverage = computeSessionSyncCoverage( + [session("s1", { repoPath: "alpha" })], + new Set(["s1", "gone-1", "gone-2"]), + scopedBy("alpha") + ); + + expect(coverage.repos[0]).toEqual({ + repoScope: "alpha", + syncable: 1, + synced: 1, + percent: 100, + }); + }); + + it("rounds each row and the total independently", () => { + const sessions = Array.from({ length: 3 }, (_, index) => + session(`s${index}`, { repoPath: "alpha" }) + ); + expect( + computeSessionSyncCoverage(sessions, new Set(["s0"]), scopedBy("alpha")) + .repos[0]?.percent + ).toBe(33); + expect( + computeSessionSyncCoverage( + sessions, + new Set(["s0", "s1"]), + scopedBy("alpha") + ).percent + ).toBe(67); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudSyncCoverage.ts b/src/features/Org2Cloud/org2CloudSyncCoverage.ts new file mode 100644 index 000000000..dd0e9381a --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.ts @@ -0,0 +1,249 @@ +/** + * Session-sync coverage PER REPO SCOPE: how much of this device's history in + * each of THIS ORG'S repos is published to it. + * + * The rows are the org's own repo scopes — not every repo on the device. Repo + * scope is the hard boundary the push pass matches against, so a repo the org + * has not scoped can never receive a session; listing it would report a 0% + * that no user action can ever move. Sessions outside every org scope (and + * sessions with no Git remote at all) are excluded from the rows AND from the + * totals, so the headline percentage means "of the work this org can receive" + * rather than "of everything on this laptop". + * + * Pure by design — the caller hands in the roster, the durable push markers, + * and the scope resolver, so the same numbers are assertable in tests and + * renderable from either sync surface (org-management → Sync, Runtime → org → + * Sync). + * + * The DENOMINATOR reuses the engine's own candidate filters so no row can + * exceed 100%: + * - `isPrimarySessionListSession` drops subagent/worker transcripts. A + * subagent is a rendering detail of its parent, never an independently + * published unit (see `importedSessionScopeMatch`), so counting one would + * inflate a denominator nothing can ever move. + * - `isCloudPushCandidate` drops teammate copies pulled DOWN from an org; + * those never round-trip back up. + * - `decidePushAdmission` applies fork provenance and the ownership/tag/ + * explicit-share-intent gate. + * - `resolveCloudPushAccess` excludes admitted sessions whose effective + * access remains off. + * + * The NUMERATOR is the durable push marker — the same evidence + * `Org2CloudSessionSyncState.wasCloudPushed` reads — so a session still counts + * as synced after a restart. The in-memory metadata-hash cache is deliberately + * NOT consulted: it dies with the app, and a count that shrinks on relaunch + * reads as data loss. + */ +import type { Session } from "@src/store/session/sessionAtom/types"; +import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; + +import { normalizeRepoScopeKey } from "../TeamCollaboration/collabSyncUtils"; +import { createSessionForkedFromResolver } from "../TeamCollaboration/forkSession"; +import { peekMatchingOrgRepoScope } from "../TeamCollaboration/repoScopeResolver"; +import { + type SessionOrgTags, + isSessionTaggedToCloudOrg, +} from "../TeamCollaboration/sessionOrgTagsAtom"; +import { + type CloudAccessSettingsByOrg, + type CloudSharingFloorByOrg, + hasExplicitCloudShareIntent, + resolveCloudPushAccess, +} from "./org2CloudAccessSettings"; +import { buildCloudOrgSelectorValue } from "./org2CloudOrgsAtom"; +import { decidePushAdmission } from "./org2CloudPushAdmission"; +import { isCloudPushCandidate } from "./org2CloudSessionSync.metadata"; +import { getSessionScopeKeys } from "./org2CloudSyncEngine.repoScopeSync"; + +/** The roster fields coverage actually reads. */ +export type SyncCoverageSession = Pick< + Session, + | "session_id" + | "orgId" + | "parentSessionId" + | "orgMemberId" + | "agentOrgId" + | "importedFrom" + | "repoPath" + | "repoRemoteUrls" + | "forkedFrom" +>; + +export type SyncCoverageEligibilityResolver = ( + session: SyncCoverageSession +) => boolean; + +export interface OrgSyncCoverageEligibilityState { + orgId: string; + tags: SessionOrgTags; + accessByOrg: CloudAccessSettingsByOrg; + floorByOrg: CloudSharingFloorByOrg; +} + +/** + * Which org repo scope a session syncs under. + * - `string` — the matched org scope; the session counts toward that row. + * - `null` — outside every org scope (or no Git remote): not syncable to + * this org, so it is excluded from the rows and the totals. + * - `undefined` — a remote/identity lookup is still in flight; excluded for + * now and picked up on the resolver's next version bump. + */ +export type RepoScopeResolver = ( + session: SyncCoverageSession +) => string | null | undefined; + +export interface RepoSyncCoverage { + /** The org repo scope this row reports on. */ + repoScope: string; + syncable: number; + synced: number; + /** 0–100, rounded. Always defined: a row exists only if it has sessions. */ + percent: number; +} + +export interface SessionSyncCoverage { + /** One entry per org repo scope that has syncable sessions, largest first. */ + repos: RepoSyncCoverage[]; + /** Totals across the rows — sessions this org can actually receive. */ + syncable: number; + synced: number; + /** 0–100, rounded. `null` when there is nothing syncable to divide by. */ + percent: number | null; +} + +/** True for local sessions that count toward a coverage denominator. */ +export function isSyncCoverageSession(session: SyncCoverageSession): boolean { + return isPrimarySessionListSession(session) && isCloudPushCandidate(session); +} + +/** + * Replay the push engine's org-admission and effective-access gates for one + * coverage snapshot. Repo matching remains a separate resolver because it + * has an async/in-flight state; every other denominator rule lives here. + */ +export function createOrgSyncCoverageEligibilityResolver({ + orgId, + tags, + accessByOrg, + floorByOrg, +}: OrgSyncCoverageEligibilityState): SyncCoverageEligibilityResolver { + const settings = accessByOrg[orgId]; + const floor = floorByOrg[orgId]; + const forkedFromForSession = createSessionForkedFromResolver(); + return (session) => { + if (!isSyncCoverageSession(session)) return false; + const tagged = isSessionTaggedToCloudOrg(tags, session.session_id, orgId); + const admission = decidePushAdmission({ + orgId, + session, + forkedFrom: forkedFromForSession(session), + tagged, + ownedByOrg: session.orgId === buildCloudOrgSelectorValue(orgId), + shareIntent: hasExplicitCloudShareIntent(settings, session.session_id), + }); + return ( + admission.admitted && + resolveCloudPushAccess(settings, session.session_id, tagged, floor) !== + null + ); + }; +} + +/** + * Resolver that maps a session onto one of `orgScopes`, mirroring the push + * pass's own matching (`getSessionScopeKeys` → `peekMatchingOrgRepoScope`): + * multi-remote aware, and the MATCHED ORG-SIDE scope string is what comes + * back — the same string the engine pushes as `repoScopeKey`, so a row is + * labelled exactly as the org has it configured. + * + * Both lookups are cache-backed and self-priming, so cold calls return + * `undefined` and warm the entries; pair this with + * `useShareableScopeKeyVersion` so rows fill in as the lookups land. + */ +export function createOrgRepoScopeResolver( + orgScopes: readonly string[] | undefined +): RepoScopeResolver { + const normalized = (orgScopes ?? []) + .map((scope) => normalizeRepoScopeKey(scope)) + .filter((scope) => scope.length > 0); + if (normalized.length === 0) return () => null; + return (session) => { + const keys = getSessionScopeKeys(session); + if (keys === undefined) return undefined; + if (keys === null) return null; + return peekMatchingOrgRepoScope(keys, normalized); + }; +} + +/** + * Session ids this device has durably published to `orgId`, read off the two + * persisted marker maps. Mirrors `Org2CloudSessionSyncState.markedSessionIds`: + * composite keys are `${orgId}:${sessionId}` and cloud org ids are uuids (no + * colon), so the prefix cut stays exact even when a session id contains one. + */ +export function pushedSessionIdsForOrg( + orgId: string, + pushedMetadata: Readonly>, + pushCursors: Readonly> +): Set { + const ids = new Set(); + const prefix = `${orgId}:`; + for (const source of [pushedMetadata, pushCursors]) { + for (const key of Object.keys(source)) { + if (key.startsWith(prefix)) ids.add(key.slice(prefix.length)); + } + } + return ids; +} + +export function computeSessionSyncCoverage( + sessions: readonly SyncCoverageSession[], + pushedSessionIds: ReadonlySet, + repoScopeForSession: RepoScopeResolver, + isEligible: SyncCoverageEligibilityResolver = isSyncCoverageSession +): SessionSyncCoverage { + const byScope = new Map(); + let syncable = 0; + let synced = 0; + + for (const session of sessions) { + if (!isEligible(session)) continue; + // null = outside every org scope, undefined = lookup in flight. Neither + // is work this org can receive today, so neither moves a number. + const repoScope = repoScopeForSession(session); + if (repoScope === null || repoScope === undefined) continue; + + // Markers for sessions that left the roster are ignored on purpose: the + // engine's vanished-session GC retracts them, and counting them here + // would report more synced than syncable. + const isSynced = pushedSessionIds.has(session.session_id); + syncable += 1; + if (isSynced) synced += 1; + + const bucket = byScope.get(repoScope) ?? { syncable: 0, synced: 0 }; + bucket.syncable += 1; + if (isSynced) bucket.synced += 1; + byScope.set(repoScope, bucket); + } + + const repos = [...byScope.entries()] + .map(([repoScope, bucket]) => ({ + repoScope, + syncable: bucket.syncable, + synced: bucket.synced, + percent: Math.round((bucket.synced / bucket.syncable) * 100), + })) + // Biggest repo first; the scope string breaks ties so the order never + // churns between renders. + .sort( + (a, b) => + b.syncable - a.syncable || a.repoScope.localeCompare(b.repoScope) + ); + + return { + repos, + syncable, + synced, + percent: syncable === 0 ? null : Math.round((synced / syncable) * 100), + }; +} diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts b/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts index 30245e593..4dc4b6902 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.metadata.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { Session } from "@src/store/session/sessionAtom/types"; + import { buildCloudSessionMetadata, isCloudPushCandidate, @@ -53,16 +55,19 @@ describe("isCloudPushCandidate", () => { }) ).toBe(false); // The user's OWN external history (no importedFrom) is now shareable. - expect( - isCloudPushCandidate({ ...SESSION, category: "external_history" }) - ).toBe(true); + // Annotated rather than passed inline: the predicate only reads + // `importedFrom`, so an inline literal trips the excess-property check on + // the narrowed parameter — `category` is the case under test, not noise. + const externalHistory: Session = { + ...SESSION, + category: "external_history", + }; + expect(isCloudPushCandidate(externalHistory)).toBe(true); // External history that is ALSO an imported copy stays excluded. - expect( - isCloudPushCandidate({ - ...SESSION, - category: "external_history", - importedFrom: { orgId: "x" } as never, - }) - ).toBe(false); + const importedExternalHistory: Session = { + ...externalHistory, + importedFrom: { orgId: "x" } as never, + }; + expect(isCloudPushCandidate(importedExternalHistory)).toBe(false); }); }); diff --git a/src/features/Org2Cloud/org2CloudSyncEngine.repoScopeSync.ts b/src/features/Org2Cloud/org2CloudSyncEngine.repoScopeSync.ts index e4c4c8427..1196c8433 100644 --- a/src/features/Org2Cloud/org2CloudSyncEngine.repoScopeSync.ts +++ b/src/features/Org2Cloud/org2CloudSyncEngine.repoScopeSync.ts @@ -26,7 +26,10 @@ const log = createLogger("Org2CloudSyncEngine"); /** ALL shareable keys for the session's checkout (multi-remote), from the * resolver cache. undefined = resolution in flight (primed here). */ export function getSessionScopeKeys( - session: Session + session: Pick< + Session, + "session_id" | "repoPath" | "repoRemoteUrls" | "parentSessionId" + > ): string[] | null | undefined { const persistedKeys = persistedScopeKeysForImportedSession(session); if (persistedKeys !== undefined) return persistedKeys; diff --git a/src/features/TeamCollaboration/forkSession.ts b/src/features/TeamCollaboration/forkSession.ts index 748a3ec72..e3431f733 100644 --- a/src/features/TeamCollaboration/forkSession.ts +++ b/src/features/TeamCollaboration/forkSession.ts @@ -175,6 +175,15 @@ export function getSessionForkedFrom( return session.forkedFrom ?? readRegistry()[session.session_id]?.forkedFrom; } +/** Snapshot resolver for batch scans, avoiding one storage parse per row. */ +export function createSessionForkedFromResolver(): ( + session: Pick +) => SessionForkedFrom | undefined { + const registry = readRegistry(); + return (session) => + session.forkedFrom ?? registry[session.session_id]?.forkedFrom; +} + // ============================================================================ // The full fork action (engine fork + backend registration + relay arming) // ============================================================================ diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index a53dec7ee..7faa49554 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -490,6 +490,10 @@ "manualRunning": "Wird synchronisiert…", "manualSuccess": "Synchronisierung abgeschlossen", "manualError": "Synchronisierung fehlgeschlagen: {{message}}", + "coverageTitle": "Sitzungsabdeckung", + "coverageSummary": "{{synced}}/{{syncable}} synchronisiert ({{percent}} %)", + "coveragePercentLabel": "Abdeckung", + "coverageEmpty": "Noch keine Sitzungen in den Repository-Bereichen dieser Organisation", "logsTitle": "Sync-Protokoll", "logsHelp": "Aktuelle Sync-Ereignisse dieses Geräts. Beim Neustart gelöscht und nie hochgeladen", "logsEmpty": "Noch keine Sync-Ereignisse aufgezeichnet", diff --git a/src/i18n/locales/de/teamRuntime.json b/src/i18n/locales/de/teamRuntime.json index f0ef28e41..e62552958 100644 --- a/src/i18n/locales/de/teamRuntime.json +++ b/src/i18n/locales/de/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Neueste geteilte Sessions", "recentLoading": "Geteilte Sessions werden geladen…", "recentEmpty": "Noch keine geteilten Sessions", - "members": "Aufschlüsselung nach Mitglied" + "members": "Aufschlüsselung nach Mitglied", + "sync": "Sync" }, "signedOut": { "title": "Melde dich an, um dein Team zu sehen", diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index d9d8942c2..5d5512015 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -516,6 +516,10 @@ "manualRunning": "Syncing…", "manualSuccess": "Sync finished", "manualError": "Sync failed: {{message}}", + "coverageTitle": "Session coverage", + "coverageSummary": "{{synced}}/{{syncable}} synced ({{percent}}%)", + "coveragePercentLabel": "Coverage", + "coverageEmpty": "No sessions in this org's repo scopes yet", "logsTitle": "Sync log", "logsHelp": "Recent sync events recorded on this device. Cleared on restart and never uploaded", "logsEmpty": "No sync events recorded yet", diff --git a/src/i18n/locales/en/teamRuntime.json b/src/i18n/locales/en/teamRuntime.json index e850df636..6021c6f81 100644 --- a/src/i18n/locales/en/teamRuntime.json +++ b/src/i18n/locales/en/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Latest shared sessions", "recentLoading": "Loading shared sessions…", "recentEmpty": "No shared sessions yet", - "members": "Member breakdown" + "members": "Member breakdown", + "sync": "Sync" }, "signedOut": { "title": "Sign in to see your team", diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index 8448ef053..72ed636b5 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -490,6 +490,10 @@ "manualRunning": "Sincronizando…", "manualSuccess": "Sincronización completada", "manualError": "Error de sincronización: {{message}}", + "coverageTitle": "Cobertura de sesiones", + "coverageSummary": "{{synced}}/{{syncable}} sincronizadas ({{percent}} %)", + "coveragePercentLabel": "Cobertura", + "coverageEmpty": "Aún no hay sesiones en los ámbitos de repositorio de esta organización", "logsTitle": "Registro de sincronización", "logsHelp": "Eventos de sincronización recientes registrados en este dispositivo. Se borran al reiniciar y nunca se suben", "logsEmpty": "Aún no hay eventos de sincronización registrados", diff --git a/src/i18n/locales/es/teamRuntime.json b/src/i18n/locales/es/teamRuntime.json index b171ce0c8..e398dbf88 100644 --- a/src/i18n/locales/es/teamRuntime.json +++ b/src/i18n/locales/es/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Últimas sesiones compartidas", "recentLoading": "Cargando sesiones compartidas…", "recentEmpty": "Aún no hay sesiones compartidas", - "members": "Desglose por miembro" + "members": "Desglose por miembro", + "sync": "Sincronización" }, "signedOut": { "title": "Inicia sesión para ver tu equipo", diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index b2698e687..f9485347a 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -490,6 +490,10 @@ "manualRunning": "Synchronisation…", "manualSuccess": "Synchronisation terminée", "manualError": "Échec de la synchronisation : {{message}}", + "coverageTitle": "Couverture des sessions", + "coverageSummary": "{{synced}}/{{syncable}} synchronisées ({{percent}} %)", + "coveragePercentLabel": "Couverture", + "coverageEmpty": "Aucune session dans les portées de dépôt de cette organisation", "logsTitle": "Journal de synchronisation", "logsHelp": "Événements de synchronisation récents enregistrés sur cet appareil. Effacés au redémarrage et jamais envoyés", "logsEmpty": "Aucun événement de synchronisation enregistré", diff --git a/src/i18n/locales/fr/teamRuntime.json b/src/i18n/locales/fr/teamRuntime.json index 487ee073c..bba7e67dd 100644 --- a/src/i18n/locales/fr/teamRuntime.json +++ b/src/i18n/locales/fr/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Dernières sessions partagées", "recentLoading": "Chargement des sessions partagées…", "recentEmpty": "Aucune session partagée", - "members": "Détail par membre" + "members": "Détail par membre", + "sync": "Synchronisation" }, "signedOut": { "title": "Connectez-vous pour voir votre équipe", diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json index 3be7cfc71..1567efa1d 100644 --- a/src/i18n/locales/ja/navigation.json +++ b/src/i18n/locales/ja/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "同期中…", "manualSuccess": "同期が完了しました", "manualError": "同期に失敗しました: {{message}}", + "coverageTitle": "セッションのカバー率", + "coverageSummary": "{{synced}}/{{syncable}} 同期済み({{percent}}%)", + "coveragePercentLabel": "カバー率", + "coverageEmpty": "この組織のリポジトリ範囲にはまだセッションがありません", "logsTitle": "同期ログ", "logsHelp": "このデバイスに記録された最近の同期イベント。再起動でクリアされ、アップロードされることはありません", "logsEmpty": "同期イベントの記録はまだありません", diff --git a/src/i18n/locales/ja/teamRuntime.json b/src/i18n/locales/ja/teamRuntime.json index 973c86f34..3f41c5c2c 100644 --- a/src/i18n/locales/ja/teamRuntime.json +++ b/src/i18n/locales/ja/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "最近共有されたセッション", "recentLoading": "共有セッションを読み込み中…", "recentEmpty": "共有セッションはまだありません", - "members": "メンバー別の内訳" + "members": "メンバー別の内訳", + "sync": "同期" }, "signedOut": { "title": "ログインしてチームを表示", diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json index fbc1fef83..5ea7a7f7f 100644 --- a/src/i18n/locales/ko/navigation.json +++ b/src/i18n/locales/ko/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "동기화 중…", "manualSuccess": "동기화 완료", "manualError": "동기화 실패: {{message}}", + "coverageTitle": "세션 커버리지", + "coverageSummary": "{{synced}}/{{syncable}} 동기화됨({{percent}}%)", + "coveragePercentLabel": "커버리지", + "coverageEmpty": "이 조직의 저장소 범위에 아직 세션이 없습니다", "logsTitle": "동기화 로그", "logsHelp": "이 기기에 기록된 최근 동기화 이벤트입니다. 재시작 시 지워지며 업로드되지 않습니다", "logsEmpty": "기록된 동기화 이벤트가 없습니다", diff --git a/src/i18n/locales/ko/teamRuntime.json b/src/i18n/locales/ko/teamRuntime.json index cdb2e15e6..a9635f726 100644 --- a/src/i18n/locales/ko/teamRuntime.json +++ b/src/i18n/locales/ko/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "최근 공유 세션", "recentLoading": "공유 세션을 불러오는 중…", "recentEmpty": "아직 공유된 세션이 없습니다", - "members": "구성원별 내역" + "members": "구성원별 내역", + "sync": "동기화" }, "signedOut": { "title": "로그인하고 팀을 확인하세요", diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json index 677269121..9c00ab684 100644 --- a/src/i18n/locales/pl/navigation.json +++ b/src/i18n/locales/pl/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "Synchronizowanie…", "manualSuccess": "Synchronizacja zakończona", "manualError": "Synchronizacja nie powiodła się: {{message}}", + "coverageTitle": "Pokrycie sesji", + "coverageSummary": "Zsynchronizowano {{synced}}/{{syncable}} ({{percent}}%)", + "coveragePercentLabel": "Pokrycie", + "coverageEmpty": "Brak sesji w zakresach repozytoriów tej organizacji", "logsTitle": "Dziennik synchronizacji", "logsHelp": "Ostatnie zdarzenia synchronizacji zapisane na tym urządzeniu. Czyszczone po restarcie i nigdy nie wysyłane", "logsEmpty": "Brak zapisanych zdarzeń synchronizacji", diff --git a/src/i18n/locales/pl/teamRuntime.json b/src/i18n/locales/pl/teamRuntime.json index eb03e1fd5..adc3105dc 100644 --- a/src/i18n/locales/pl/teamRuntime.json +++ b/src/i18n/locales/pl/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Najnowsze udostępnione sesje", "recentLoading": "Wczytywanie udostępnionych sesji…", "recentEmpty": "Brak udostępnionych sesji", - "members": "Podział według członka" + "members": "Podział według członka", + "sync": "Synchronizacja" }, "signedOut": { "title": "Zaloguj się, aby zobaczyć swój zespół", diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index 4ad567abe..12e5deaa7 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -490,6 +490,10 @@ "manualRunning": "Sincronizando…", "manualSuccess": "Sincronização concluída", "manualError": "Falha na sincronização: {{message}}", + "coverageTitle": "Cobertura de sessões", + "coverageSummary": "{{synced}}/{{syncable}} sincronizadas ({{percent}}%)", + "coveragePercentLabel": "Cobertura", + "coverageEmpty": "Ainda não há sessões nos escopos de repositório desta organização", "logsTitle": "Registro de sincronização", "logsHelp": "Eventos de sincronização recentes registrados neste dispositivo. Limpos ao reiniciar e nunca enviados", "logsEmpty": "Nenhum evento de sincronização registrado", diff --git a/src/i18n/locales/pt/teamRuntime.json b/src/i18n/locales/pt/teamRuntime.json index 480267809..5c05c40fc 100644 --- a/src/i18n/locales/pt/teamRuntime.json +++ b/src/i18n/locales/pt/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Sessões compartilhadas recentes", "recentLoading": "Carregando sessões compartilhadas…", "recentEmpty": "Ainda não há sessões compartilhadas", - "members": "Detalhamento por membro" + "members": "Detalhamento por membro", + "sync": "Sincronização" }, "signedOut": { "title": "Entre para ver sua equipe", diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json index 3eaae659b..34d1774ea 100644 --- a/src/i18n/locales/ru/navigation.json +++ b/src/i18n/locales/ru/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "Синхронизация…", "manualSuccess": "Синхронизация завершена", "manualError": "Ошибка синхронизации: {{message}}", + "coverageTitle": "Охват сессий", + "coverageSummary": "Синхронизировано {{synced}}/{{syncable}} ({{percent}} %)", + "coveragePercentLabel": "Охват", + "coverageEmpty": "В областях репозиториев этой организации пока нет сессий", "logsTitle": "Журнал синхронизации", "logsHelp": "Недавние события синхронизации на этом устройстве. Очищаются при перезапуске и никогда не выгружаются", "logsEmpty": "События синхронизации пока не записаны", diff --git a/src/i18n/locales/ru/teamRuntime.json b/src/i18n/locales/ru/teamRuntime.json index 50bc2a9c0..4fd84d7c9 100644 --- a/src/i18n/locales/ru/teamRuntime.json +++ b/src/i18n/locales/ru/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Последние общие сессии", "recentLoading": "Загрузка общих сессий…", "recentEmpty": "Общих сессий пока нет", - "members": "Разбивка по участникам" + "members": "Разбивка по участникам", + "sync": "Синхронизация" }, "signedOut": { "title": "Войдите, чтобы увидеть свою команду", diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json index 9af92a67e..765498378 100644 --- a/src/i18n/locales/tr/navigation.json +++ b/src/i18n/locales/tr/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "Eşitleniyor…", "manualSuccess": "Eşitleme tamamlandı", "manualError": "Eşitleme başarısız: {{message}}", + "coverageTitle": "Oturum kapsamı", + "coverageSummary": "{{synced}}/{{syncable}} eşitlendi (%{{percent}})", + "coveragePercentLabel": "Kapsam", + "coverageEmpty": "Bu kuruluşun depo kapsamlarında henüz oturum yok", "logsTitle": "Eşitleme günlüğü", "logsHelp": "Bu cihazda kaydedilen son eşitleme olayları. Yeniden başlatmada silinir ve hiçbir zaman yüklenmez", "logsEmpty": "Henüz eşitleme olayı kaydedilmedi", diff --git a/src/i18n/locales/tr/teamRuntime.json b/src/i18n/locales/tr/teamRuntime.json index 5be18d634..86256ee2a 100644 --- a/src/i18n/locales/tr/teamRuntime.json +++ b/src/i18n/locales/tr/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Son paylaşılan oturumlar", "recentLoading": "Paylaşılan oturumlar yükleniyor…", "recentEmpty": "Henüz paylaşılan oturum yok", - "members": "Üye dökümü" + "members": "Üye dökümü", + "sync": "Eşitleme" }, "signedOut": { "title": "Ekibinizi görmek için giriş yapın", diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json index d13186691..9eec99143 100644 --- a/src/i18n/locales/vi/navigation.json +++ b/src/i18n/locales/vi/navigation.json @@ -488,6 +488,10 @@ "manualRunning": "Đang đồng bộ…", "manualSuccess": "Đồng bộ hoàn tất", "manualError": "Đồng bộ thất bại: {{message}}", + "coverageTitle": "Mức bao phủ phiên", + "coverageSummary": "Đã đồng bộ {{synced}}/{{syncable}} ({{percent}}%)", + "coveragePercentLabel": "Mức bao phủ", + "coverageEmpty": "Chưa có phiên nào trong phạm vi kho lưu trữ của tổ chức này", "logsTitle": "Nhật ký đồng bộ", "logsHelp": "Các sự kiện đồng bộ gần đây được ghi trên thiết bị này. Bị xóa khi khởi động lại và không bao giờ được tải lên", "logsEmpty": "Chưa ghi nhận sự kiện đồng bộ nào", diff --git a/src/i18n/locales/vi/teamRuntime.json b/src/i18n/locales/vi/teamRuntime.json index 7b7d06ec7..f92301c04 100644 --- a/src/i18n/locales/vi/teamRuntime.json +++ b/src/i18n/locales/vi/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "Phiên được chia sẻ gần đây", "recentLoading": "Đang tải các phiên được chia sẻ…", "recentEmpty": "Chưa có phiên nào được chia sẻ", - "members": "Chi tiết theo thành viên" + "members": "Chi tiết theo thành viên", + "sync": "Đồng bộ" }, "signedOut": { "title": "Đăng nhập để xem nhóm của bạn", diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json index 80d28684a..8729f77c8 100644 --- a/src/i18n/locales/zh-Hant/navigation.json +++ b/src/i18n/locales/zh-Hant/navigation.json @@ -579,6 +579,10 @@ "manualRunning": "同步中…", "manualSuccess": "同步完成", "manualError": "同步失敗:{{message}}", + "coverageTitle": "工作階段涵蓋率", + "coverageSummary": "已同步 {{synced}}/{{syncable}}({{percent}}%)", + "coveragePercentLabel": "涵蓋率", + "coverageEmpty": "此組織的存放庫範圍內尚無工作階段", "logsTitle": "同步紀錄", "logsHelp": "本機記錄的近期同步事件。重新啟動後清空,且不會上傳", "logsEmpty": "尚無同步事件紀錄", diff --git a/src/i18n/locales/zh-Hant/teamRuntime.json b/src/i18n/locales/zh-Hant/teamRuntime.json index 9b38a0615..9a5848769 100644 --- a/src/i18n/locales/zh-Hant/teamRuntime.json +++ b/src/i18n/locales/zh-Hant/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "最近共享的工作階段", "recentLoading": "正在載入共享工作階段…", "recentEmpty": "尚無共享工作階段", - "members": "成員明細" + "members": "成員明細", + "sync": "同步" }, "signedOut": { "title": "登入後查看團隊", diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index 5e62ea6d1..24f2240f9 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -579,6 +579,10 @@ "manualRunning": "同步中…", "manualSuccess": "同步完成", "manualError": "同步失败:{{message}}", + "coverageTitle": "会话覆盖率", + "coverageSummary": "已同步 {{synced}}/{{syncable}}({{percent}}%)", + "coveragePercentLabel": "覆盖率", + "coverageEmpty": "此组织的仓库范围内暂无会话", "logsTitle": "同步日志", "logsHelp": "本机记录的近期同步事件。重启后清空,且不会上传", "logsEmpty": "暂无同步事件记录", diff --git a/src/i18n/locales/zh/teamRuntime.json b/src/i18n/locales/zh/teamRuntime.json index b082c84ca..8d45ca5c6 100644 --- a/src/i18n/locales/zh/teamRuntime.json +++ b/src/i18n/locales/zh/teamRuntime.json @@ -11,7 +11,8 @@ "recentSessions": "最近共享的会话", "recentLoading": "正在加载共享会话…", "recentEmpty": "还没有共享会话", - "members": "成员明细" + "members": "成员明细", + "sync": "同步" }, "signedOut": { "title": "登录后查看团队", diff --git a/src/modules/shared/dataSource/RuntimeDataSourcePanel.test.ts b/src/modules/shared/dataSource/RuntimeDataSourcePanel.test.ts index 6e5a97275..170ae5a23 100644 --- a/src/modules/shared/dataSource/RuntimeDataSourcePanel.test.ts +++ b/src/modules/shared/dataSource/RuntimeDataSourcePanel.test.ts @@ -91,6 +91,17 @@ vi.mock("./TeamRuntimePanel", () => ({ }), })); +vi.mock( + "@src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncTab", + () => ({ + default: ({ orgId }: { orgId: string }) => + createElement("div", { + "data-testid": "runtime-section-org-sync", + "data-org-id": orgId, + }), + }) +); + vi.mock("./SessionUsagePanel", async () => { const React = await vi.importActual("react"); function UsageSectionMock() { @@ -333,5 +344,17 @@ describe("RuntimeDataSourcePanel", () => { .querySelector('[data-testid="runtime-section-organization"]') ?.getAttribute("data-view") ).toBe("members"); + + // Sync is the org-management Sync tab rendered here, so it takes the org + // id directly instead of a TeamRuntimePanel view. + await selectSection("data-source-view-org-sync"); + expect( + container.querySelector('[data-testid="runtime-section-organization"]') + ).toBeNull(); + expect( + container + .querySelector('[data-testid="runtime-section-org-sync"]') + ?.getAttribute("data-org-id") + ).toBe("org-1"); }); }); diff --git a/src/modules/shared/dataSource/index.tsx b/src/modules/shared/dataSource/index.tsx index 5fdb79263..0103db63d 100644 --- a/src/modules/shared/dataSource/index.tsx +++ b/src/modules/shared/dataSource/index.tsx @@ -31,11 +31,17 @@ type PersonalRuntimeSection = | "scanning" | "hooks" | "assets"; -export type OrganizationRuntimeSection = "today" | "members"; +export type OrganizationRuntimeSection = "today" | "members" | "sync"; type RuntimeSection = PersonalRuntimeSection | OrganizationRuntimeSection; const SessionUsagePanel = lazy(() => import("./SessionUsagePanel")); const TeamRuntimePanel = lazy(() => import("./TeamRuntimePanel")); +// Same tab as org management → Sync; both mount the one wired component so +// the two surfaces cannot drift. +const CloudOrgSyncTab = lazy( + () => + import("@src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncTab") +); const BuilderProfilePanel = lazy(() => import("./BuilderProfilePanel")); const RuntimeScanningPanel = lazy(() => import("./RuntimeScanningPanel")); const SessionProvenanceHooksPanel = lazy( @@ -116,6 +122,11 @@ const RuntimeSectionTabs: React.FC = memo( label: tTeamRuntime("overview.members"), dataTestId: "data-source-view-org-members", }, + { + key: "sync", + label: tTeamRuntime("overview.sync"), + dataTestId: "data-source-view-org-sync", + }, ], [tTeamRuntime] ); @@ -139,7 +150,7 @@ function RuntimeSectionContent({ }: { activeView: RuntimeSection; orgId: string | null; -}): React.ReactElement { +}): React.ReactElement | null { switch (activeView) { case "usage": return ; @@ -157,6 +168,10 @@ function RuntimeSectionContent({ return ; case "members": return ; + case "sync": + // Only reachable under a cloud-org scope, which is exactly when orgId is + // non-null; the personal scope has no sync tab to fall back to. + return orgId === null ? null : ; } } diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.test.ts new file mode 100644 index 000000000..3edb9633d --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; + +import { mergeCloudSidebarSections } from "./sidebarConnector.cloudMenuData"; + +describe("mergeCloudSidebarSections", () => { + it("places team channels above team conversations", () => { + const channels: NavigationMenuItem[] = [ + { + id: "separator-cloud-channels", + key: "separator-cloud-channels", + label: "Channels", + }, + { id: "cloud-channel-1", key: "cloud-channel-1", label: "general" }, + ]; + const conversations: NavigationMenuItem[] = [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team conversations", + }, + { + id: "cloud-session-1", + key: "cloud-session-1", + label: "Conversation", + }, + ]; + + expect(mergeCloudSidebarSections(channels, conversations)).toEqual([ + ...channels, + ...conversations, + ]); + }); + + it("keeps team conversations unchanged when channels are unavailable", () => { + const conversations: NavigationMenuItem[] = [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team conversations", + }, + ]; + + expect(mergeCloudSidebarSections([], conversations)).toEqual(conversations); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.ts index 28e1f2cb5..21aab9c43 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.ts @@ -6,7 +6,7 @@ * (`cloudScopedExtraSessionIds`). * * Also mounts the cloud-org "Channels" section (`channelsSection.tsx`): its - * rows join `cloudMenuItems` between Team Sessions and My Sessions, its + * rows join `cloudMenuItems` above Team Sessions and My Sessions, its * click resolver runs before the team-sessions one, its selected row (the * channel whose surface is the active chat-panel tab) takes precedence over * the team-sessions selection, and its dialogs surface through @@ -34,6 +34,15 @@ interface UseWorkstationSidebarCloudMenuDataParams { cloudTaggedSessionIds: ReadonlySet | undefined; } +export function mergeCloudSidebarSections( + channelsMenuItems: readonly NavigationMenuItem[], + cloudSessionMenuItems: readonly NavigationMenuItem[] +): NavigationMenuItem[] { + return channelsMenuItems.length === 0 + ? [...cloudSessionMenuItems] + : [...channelsMenuItems, ...cloudSessionMenuItems]; +} + export function useWorkstationSidebarCloudMenuData({ activeCloudOrgId, sessions, @@ -77,13 +86,10 @@ export function useWorkstationSidebarCloudMenuData({ channelsDialogs, } = useCloudChannelsSection({ orgId: activeCloudOrgId }); - // Channels sit after Team Sessions (both are org-scoped server data); the - // My Sessions separator is appended downstream by buildCloudScopedMenuItems. + // Channels lead Team Sessions; the My Sessions separator is appended + // downstream by buildCloudScopedMenuItems. const mergedCloudMenuItems = useMemo( - () => - channelsMenuItems.length === 0 - ? cloudMenuItems - : [...cloudMenuItems, ...channelsMenuItems], + () => mergeCloudSidebarSections(channelsMenuItems, cloudMenuItems), [channelsMenuItems, cloudMenuItems] );