Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
134 changes: 119 additions & 15 deletions src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncSection.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -52,6 +53,64 @@ const LEVEL_LABEL_KEYS: Record<SyncJournalEntry["level"], string> = {
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 (
<SectionRow
dataTestId="cloud-org-sync-coverage-repo"
label={
// Matches how CloudOrgRepoScopesSection renders the same strings.
<span className="min-w-0 truncate" title={row.repoScope}>
{row.repoScope}
</span>
}
truncateLabel
>
<div className="flex items-center justify-end gap-2.5">
<span
className="text-[12px] tabular-nums text-text-3"
data-testid="cloud-org-sync-coverage-repo-count"
>
{`${row.synced.toLocaleString()}/${row.syncable.toLocaleString()}`}
</span>
<div
className="h-1.5 w-16 shrink-0 overflow-hidden rounded-full bg-fill-2"
role="progressbar"
aria-valuenow={row.percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("cloud.orgPanel.sync.coveragePercentLabel")}
>
<div
className="h-full rounded-full bg-success-6 transition-[width] duration-300"
style={{ width: coverageBarWidth(row) }}
/>
</div>
<span
className="w-9 shrink-0 text-right text-[12px] font-medium tabular-nums text-text-2"
data-testid="cloud-org-sync-coverage-repo-percent"
>
{`${row.percent}%`}
</span>
</div>
</SectionRow>
);
}

/** 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();
Expand Down Expand Up @@ -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 (
<>
Expand Down Expand Up @@ -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. */}
<div className={`${SECTION_ACTION_GAP_CLASSES} flex-wrap`}>
<Button
htmlType="button"
size="default"
variant="primary"
disabled={status.running}
loading={status.running}
data-testid="cloud-org-sync-run"
onClick={status.runSync}
>
{status.running
? t("cloud.orgPanel.sync.manualRunning")
: t("cloud.orgPanel.sync.manualAction")}
</Button>
{status.runError ? (
<span
className="text-[12px] text-danger-6"
Expand All @@ -189,10 +252,51 @@ export function CloudOrgSyncSection({ t, status }: CloudOrgSyncSectionProps) {
{t("cloud.orgPanel.sync.manualSuccess")}
</span>
) : null}
<Button
htmlType="button"
size="default"
variant="primary"
disabled={status.running}
loading={status.running}
data-testid="cloud-org-sync-run"
onClick={status.runSync}
>
{status.running
? t("cloud.orgPanel.sync.manualRunning")
: t("cloud.orgPanel.sync.manualAction")}
</Button>
</div>
</SectionRow>
</SectionContainer>

{/* Totals ride in the title so the body stays strictly one row per
repo — the whole-device number is context, not a competing row. */}
<SectionContainer title={coverageTitle}>
{status.coverageLoading ? (
<SectionRow
dataTestId="cloud-org-sync-coverage-loading"
label={t("cloud.orgPanel.loading")}
light
/>
) : status.coverageUnavailable ? (
<SectionRow
dataTestId="cloud-org-sync-coverage-unavailable"
label={t("cloud.orgPanel.loadError")}
light
/>
) : coverage.repos.length === 0 ? (
<SectionRow
dataTestId="cloud-org-sync-coverage-empty"
label={t("cloud.orgPanel.sync.coverageEmpty")}
light
/>
) : (
coverage.repos.map((row) => (
<CoverageRow key={row.repoScope} t={t} row={row} />
))
)}
</SectionContainer>

<SectionContainer title={t("cloud.orgPanel.sync.connectionTitle")}>
<SectionRow
dataTestId="cloud-org-sync-endpoint"
Expand Down
28 changes: 28 additions & 0 deletions src/engines/ChatPanel/panels/CloudOrgPanelView/CloudOrgSyncTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Self-wiring Sync tab: `useCloudOrgSyncStatus` + `CloudOrgSyncSection`.
*
* Two surfaces render the same Sync tab — org management (this panel) and
* Runtime → org → Sync. Both mount THIS component rather than pairing the hook
* with the section themselves, so a change to either side lands in both places
* at once; a second pairing is how the two tabs drift apart.
*
* Mounting the hook here also scopes its one-shot schema/capability probes to
* the tab actually being open, instead of firing on every panel mount.
*/
import React from "react";
import { useTranslation } from "react-i18next";

import CloudOrgSyncSection from "./CloudOrgSyncSection";
import { useCloudOrgSyncStatus } from "./useCloudOrgSyncStatus";

interface CloudOrgSyncTabProps {
orgId: string;
}

export function CloudOrgSyncTab({ orgId }: CloudOrgSyncTabProps) {
const { t } = useTranslation("navigation");
const status = useCloudOrgSyncStatus(orgId);
return <CloudOrgSyncSection t={t} status={status} />;
}

export default CloudOrgSyncTab;
Loading
Loading