From b6f0d219737a6d03c9123f45066073e114aaa977 Mon Sep 17 00:00:00 2001 From: SudoMaggie <268191359+sudomaggie@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:59:57 +0800 Subject: [PATCH 1/3] feat(cloud-sync): add Runtime Sync tab and per-repo sync coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime → cloud org gains a Sync tab rendering the same content as org management → Sync. Both surfaces now mount one wired CloudOrgSyncTab so they cannot drift apart. Adds a Session coverage section to that shared tab: one row per repo scope the org has configured, showing synced/syncable and a percentage, with the device-wide total in the section title. Rows are scoped to the org's own repos. A session is resolved through the same path the push pass uses (getSessionScopeKeys → peekMatchingOrgRepoScope against the org's repo-scope mirror), so a row is labelled with the exact org-side scope string the engine pushes as repoScopeKey. Sessions outside every org scope are excluded from both the rows and the totals, so the percentage means "of the work this org can receive". Denominators reuse the engine's own candidate filters: subagent and worker transcripts are dropped (isPrimarySessionListSession), as are teammate copies pulled down from an org (isCloudPushCandidate). The numerator is the durable push marker, so counts survive a restart. The manual-sync outcome note now renders before the primary button so it sits to its left in the right-aligned control cell. Pre-commit hook ran. Total eslint: 1, total circular: 0 --- .../CloudOrgSyncSection.test.ts | 127 +++++++++ .../CloudOrgPanelView/CloudOrgSyncSection.tsx | 120 +++++++-- .../CloudOrgPanelView/CloudOrgSyncTab.tsx | 28 ++ .../panels/CloudOrgPanelView/index.tsx | 6 +- .../useCloudOrgSyncStatus.test.ts | 146 ++++++++++ .../useCloudOrgSyncStatus.ts | 48 ++++ .../org2CloudSessionSync.metadata.ts | 4 +- .../Org2Cloud/org2CloudSyncCoverage.test.ts | 254 ++++++++++++++++++ .../Org2Cloud/org2CloudSyncCoverage.ts | 185 +++++++++++++ .../org2CloudSyncEngine.metadata.test.ts | 25 +- .../org2CloudSyncEngine.repoScopeSync.ts | 5 +- src/i18n/locales/de/navigation.json | 4 + src/i18n/locales/de/teamRuntime.json | 3 +- src/i18n/locales/en/navigation.json | 4 + src/i18n/locales/en/teamRuntime.json | 3 +- src/i18n/locales/es/navigation.json | 4 + src/i18n/locales/es/teamRuntime.json | 3 +- src/i18n/locales/fr/navigation.json | 4 + src/i18n/locales/fr/teamRuntime.json | 3 +- src/i18n/locales/ja/navigation.json | 4 + src/i18n/locales/ja/teamRuntime.json | 3 +- src/i18n/locales/ko/navigation.json | 4 + src/i18n/locales/ko/teamRuntime.json | 3 +- src/i18n/locales/pl/navigation.json | 4 + src/i18n/locales/pl/teamRuntime.json | 3 +- src/i18n/locales/pt/navigation.json | 4 + src/i18n/locales/pt/teamRuntime.json | 3 +- src/i18n/locales/ru/navigation.json | 4 + src/i18n/locales/ru/teamRuntime.json | 3 +- src/i18n/locales/tr/navigation.json | 4 + src/i18n/locales/tr/teamRuntime.json | 3 +- src/i18n/locales/vi/navigation.json | 4 + src/i18n/locales/vi/teamRuntime.json | 3 +- src/i18n/locales/zh-Hant/navigation.json | 4 + src/i18n/locales/zh-Hant/teamRuntime.json | 3 +- src/i18n/locales/zh/navigation.json | 4 + src/i18n/locales/zh/teamRuntime.json | 3 +- .../dataSource/RuntimeDataSourcePanel.test.ts | 23 ++ src/modules/shared/dataSource/index.tsx | 19 +- 39 files changed, 1035 insertions(+), 46 deletions(-) create mode 100644 src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncTab.tsx create mode 100644 src/features/Org2Cloud/org2CloudSyncCoverage.test.ts create mode 100644 src/features/Org2Cloud/org2CloudSyncCoverage.ts diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts index 2968f178bd..2e4fa7f990 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,98 @@ describe("CloudOrgSyncSection connection block", () => { }); }); +describe("CloudOrgSyncSection coverage block", () => { + 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 +374,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 995d35d6ea..60dfee7158 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,18 @@ 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 = + 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 +229,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. */} + + {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 90722e9322..f9c35cfc09 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 2e341df105..5244c66d45 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.test.ts @@ -1,8 +1,16 @@ // @vitest-environment jsdom +import { getDefaultStore } from "jotai"; import { createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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"; @@ -82,8 +90,25 @@ beforeEach(() => { afterEach(() => { resetSyncJournalForTests(); + const store = getDefaultStore(); + store.set(sessionsAtom, []); + store.set(org2CloudPushedMetadataAtom, {}); + store.set(org2CloudPushCursorsAtom, {}); + store.set(org2CloudRepoScopesAtom, {}); }); +/** + * `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, ...overrides } as Session; +} + describe("useCloudOrgSyncStatus", () => { it("exposes the backend kind but never the endpoint URL or the anon key", async () => { const probe = mountStatus(); @@ -158,6 +183,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 3cc6b32be7..e77fc2b4ea 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts @@ -24,6 +24,17 @@ import type { CloudCapabilities } from "@src/features/Org2Cloud/org2CloudCapabil 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, + pushedSessionIdsForOrg, +} from "@src/features/Org2Cloud/org2CloudSyncCoverage"; import { org2CloudSyncEngine } from "@src/features/Org2Cloud/org2CloudSyncEngine"; import { type SyncJournalEntry, @@ -33,6 +44,8 @@ import { useLastSyncState, useSyncJournal, } from "@src/features/Org2Cloud/org2CloudSyncJournal"; +import { useShareableScopeKeyVersion } from "@src/features/TeamCollaboration/repoScopeResolver"; +import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; export type SchemaProbeStatus = | "checking" @@ -53,6 +66,8 @@ export interface CloudOrgSyncStatus { capabilities: CloudCapabilities | null; capabilitiesLoading: boolean; lastSync: SyncJournalLastSyncState; + /** Per-repo publish coverage across THIS org's repo scopes. */ + coverage: SessionSyncCoverage; entries: readonly SyncJournalEntry[]; running: boolean; runSucceeded: boolean; @@ -70,6 +85,38 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { const accessToken = auth?.accessToken ?? null; const endpoint = useMemo(() => endpointForOrg(orgId), [orgId]); + // Coverage is derived, not probed: both marker atoms and the roster are + // already-resolved local state, so the numbers track pushes and retracts + // live without this diagnostic tab polling anything. + const sessions = useAtomValue(sessionsAtom); + const pushedMetadata = useAtomValue(org2CloudPushedMetadataAtom); + const pushCursors = useAtomValue(org2CloudPushCursorsAtom); + // 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( + sessions, + pushedSessionIdsForOrg(orgId, pushedMetadata, pushCursors), + createOrgRepoScopeResolver(orgScopes) + ); + }, [ + orgId, + orgScopes, + pushCursors, + pushedMetadata, + scopeKeyVersion, + sessions, + ]); + const [schemaStatus, setSchemaStatus] = useState("checking"); const [backendSchemaVersion, setBackendSchemaVersion] = useState< @@ -181,6 +228,7 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { capabilities, capabilitiesLoading, lastSync, + coverage, entries, running, runSucceeded, diff --git a/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts b/src/features/Org2Cloud/org2CloudSessionSync.metadata.ts index 0ce1127ded..202607c58f 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 0000000000..077373f057 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; + +import { + type RepoScopeResolver, + type SyncCoverageSession, + computeSessionSyncCoverage, + 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("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 0000000000..d24d751d50 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.ts @@ -0,0 +1,185 @@ +/** + * 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. + * + * 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 { peekMatchingOrgRepoScope } from "../TeamCollaboration/repoScopeResolver"; +import { isCloudPushCandidate } from "./org2CloudSessionSync.metadata"; +import { getSessionScopeKeys } from "./org2CloudSyncEngine.repoScopeSync"; + +/** The roster fields coverage actually reads. */ +export type SyncCoverageSession = Pick< + Session, + | "session_id" + | "parentSessionId" + | "orgMemberId" + | "agentOrgId" + | "importedFrom" + | "repoPath" + | "repoRemoteUrls" +>; + +/** + * 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); +} + +/** + * 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 +): SessionSyncCoverage { + const byScope = new Map(); + let syncable = 0; + let synced = 0; + + for (const session of sessions) { + if (!isSyncCoverageSession(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 30245e5933..4dc4b69028 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 e4c4c8427f..1196c84338 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/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index a53dec7ee1..7faa49554c 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 f0ef28e41c..e625529586 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 d9d8942c27..5d5512015a 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 e850df636b..6021c6f818 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 8448ef053e..72ed636b50 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 b171ce0c84..e398dbf88b 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 b2698e6875..f9485347ae 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 487ee073ca..bba7e67dd9 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 3be7cfc71f..1567efa1de 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 973c86f34e..3f41c5c2c4 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 fbc1fef83f..5ea7a7f7f5 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 cdb2e15e6a..a9635f7262 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 677269121a..9c00ab684d 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 eb03e1fd5a..adc3105dc8 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 4ad567abe3..12e5deaa78 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 480267809a..5c05c40fc7 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 3eaae659b2..34d1774ea2 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 50bc2a9c09..4fd84d7c91 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 9af92a67e3..765498378d 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 5be18d634a..86256ee2ad 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 d13186691c..9eec991433 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 7b7d06ec76..f92301c048 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 80d28684a2..8729f77c8b 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 9b38a06156..9a5848769c 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 5e62ea6d18..24f2240f91 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 b082c84ca9..8d45ca5c64 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 6e5a972755..170ae5a239 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 5fdb792634..0103db63dc 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 : ; } } From b3bcea1cb6a61434f30f40b0a6056a18d7c7fc37 Mon Sep 17 00:00:00 2001 From: SudoMaggie <268191359+sudomaggie@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:03:42 +0800 Subject: [PATCH 2/3] feat(sidebar): lead the cloud org menu with Channels Channels rows now sort above Team Sessions instead of below. Both are org-scoped server data, but Channels is the smaller, stable set and reads better as the section header of the cloud scope; Team Sessions grows without bound and pushed Channels off-screen. Extracts the merge into mergeCloudSidebarSections so the ordering is covered by a unit test rather than only observable through the assembled sidebar. The My Sessions separator is still appended downstream by buildCloudScopedMenuItems. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../sidebarConnector.cloudMenuData.test.ts | 47 +++++++++++++++++++ .../sidebarConnector.cloudMenuData.ts | 20 +++++--- 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.cloudMenuData.test.ts 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 0000000000..3edb9633de --- /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 28e1f2cb54..21aab9c438 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] ); From 869519bea9ec0f324ea51ed1bdc3c1ad0cb97456 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Sun, 2 Aug 2026 20:05:20 +0800 Subject: [PATCH 3/3] fix(cloud-sync): align coverage with push eligibility --- .../CloudOrgSyncSection.test.ts | 32 ++++ .../CloudOrgPanelView/CloudOrgSyncSection.tsx | 16 +- .../useCloudOrgSyncStatus.test.ts | 76 +++++++- .../useCloudOrgSyncStatus.ts | 176 +++++++++++++++++- .../Org2Cloud/org2CloudSyncCoverage.test.ts | 65 +++++++ .../Org2Cloud/org2CloudSyncCoverage.ts | 68 ++++++- src/features/TeamCollaboration/forkSession.ts | 9 + 7 files changed, 428 insertions(+), 14 deletions(-) diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts index 2e4fa7f990..7fea222a28 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.test.ts @@ -209,6 +209,38 @@ 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(); diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx index 60dfee7158..b8186b0a67 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx @@ -179,6 +179,8 @@ export function CloudOrgSyncSection({ t, status }: CloudOrgSyncSectionProps) { 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( @@ -270,7 +272,19 @@ export function CloudOrgSyncSection({ t, status }: CloudOrgSyncSectionProps) { {/* Totals ride in the title so the body stays strictly one row per repo — the whole-device number is context, not a competing row. */} - {coverage.repos.length === 0 ? ( + {status.coverageLoading ? ( + + ) : status.coverageUnavailable ? ( + + ) : coverage.repos.length === 0 ? ( ({ 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", () => ({ @@ -86,6 +101,14 @@ 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(() => { @@ -95,6 +118,8 @@ afterEach(() => { store.set(org2CloudPushedMetadataAtom, {}); store.set(org2CloudPushCursorsAtom, {}); store.set(org2CloudRepoScopesAtom, {}); + store.set(org2CloudAccessSettingsAtom, {}); + store.set(org2CloudSharingFloorAtom, {}); }); /** @@ -106,10 +131,57 @@ function localSession( session_id: string, overrides: Partial = {} ): Session { - return { session_id, ...overrides } as 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 { diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts index e77fc2b4ea..f8a50a7695 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/useCloudOrgSyncStatus.ts @@ -15,10 +15,19 @@ * 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"; @@ -33,6 +42,7 @@ import { type SessionSyncCoverage, computeSessionSyncCoverage, createOrgRepoScopeResolver, + createOrgSyncCoverageEligibilityResolver, pushedSessionIdsForOrg, } from "@src/features/Org2Cloud/org2CloudSyncCoverage"; import { org2CloudSyncEngine } from "@src/features/Org2Cloud/org2CloudSyncEngine"; @@ -45,7 +55,65 @@ import { 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" @@ -68,6 +136,10 @@ export interface CloudOrgSyncStatus { 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; @@ -78,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(); @@ -85,12 +158,84 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { const accessToken = auth?.accessToken ?? null; const endpoint = useMemo(() => endpointForOrg(orgId), [orgId]); - // Coverage is derived, not probed: both marker atoms and the roster are - // already-resolved local state, so the numbers track pushes and retracts - // live without this diagnostic tab polling anything. - const sessions = useAtomValue(sessionsAtom); + 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. @@ -104,9 +249,15 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { // what makes it an honest dependency rather than a suppressed lint rule. void scopeKeyVersion; return computeSessionSyncCoverage( - sessions, + coverageSessions, pushedSessionIdsForOrg(orgId, pushedMetadata, pushCursors), - createOrgRepoScopeResolver(orgScopes) + createOrgRepoScopeResolver(orgScopes), + createOrgSyncCoverageEligibilityResolver({ + orgId, + tags, + accessByOrg, + floorByOrg, + }) ); }, [ orgId, @@ -114,7 +265,10 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { pushCursors, pushedMetadata, scopeKeyVersion, - sessions, + accessByOrg, + coverageSessions, + floorByOrg, + tags, ]); const [schemaStatus, setSchemaStatus] = @@ -202,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; @@ -210,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 @@ -229,6 +385,8 @@ export function useCloudOrgSyncStatus(orgId: string): CloudOrgSyncStatus { capabilitiesLoading, lastSync, coverage, + coverageLoading, + coverageUnavailable, entries, running, runSucceeded, diff --git a/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts b/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts index 077373f057..6895bc56c9 100644 --- a/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.test.ts @@ -4,6 +4,7 @@ import { type RepoScopeResolver, type SyncCoverageSession, computeSessionSyncCoverage, + createOrgSyncCoverageEligibilityResolver, isSyncCoverageSession, pushedSessionIdsForOrg, } from "./org2CloudSyncCoverage"; @@ -73,6 +74,70 @@ describe("isSyncCoverageSession", () => { }); }); +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( diff --git a/src/features/Org2Cloud/org2CloudSyncCoverage.ts b/src/features/Org2Cloud/org2CloudSyncCoverage.ts index d24d751d50..dd0e9381a6 100644 --- a/src/features/Org2Cloud/org2CloudSyncCoverage.ts +++ b/src/features/Org2Cloud/org2CloudSyncCoverage.ts @@ -23,6 +23,10 @@ * 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 @@ -34,7 +38,20 @@ 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"; @@ -42,14 +59,27 @@ import { getSessionScopeKeys } from "./org2CloudSyncEngine.repoScopeSync"; 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. @@ -86,6 +116,39 @@ 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`): @@ -136,14 +199,15 @@ export function pushedSessionIdsForOrg( export function computeSessionSyncCoverage( sessions: readonly SyncCoverageSession[], pushedSessionIds: ReadonlySet, - repoScopeForSession: RepoScopeResolver + repoScopeForSession: RepoScopeResolver, + isEligible: SyncCoverageEligibilityResolver = isSyncCoverageSession ): SessionSyncCoverage { const byScope = new Map(); let syncable = 0; let synced = 0; for (const session of sessions) { - if (!isSyncCoverageSession(session)) continue; + 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); diff --git a/src/features/TeamCollaboration/forkSession.ts b/src/features/TeamCollaboration/forkSession.ts index 748a3ec723..e3431f733a 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) // ============================================================================