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
11 changes: 8 additions & 3 deletions packages/ui/src/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/* Global application state — shared across tabs. */

import type { UiSyncViewModel } from "../tabs/sync/view-model";
import type {
TeamSyncDaemonState,
TeamSyncPresenceState,
UiSyncViewModel,
} from "../tabs/sync/view-model";
import type { ShareOperationReadModel } from "./api/sync";

export type RefreshState = "idle" | "refreshing" | "paused" | "error";
Expand Down Expand Up @@ -79,7 +83,8 @@ export interface CachedRawEventsPayload {
}

export interface CachedSyncStatus {
daemon_state?: string;
daemon_state?: TeamSyncDaemonState;
daemon_running?: boolean;
enabled?: boolean;
last_sync_at?: string;
last_sync_at_utc?: string;
Expand Down Expand Up @@ -158,7 +163,7 @@ export interface CachedSyncCoordinator {
groups?: unknown[];
coordinator_url?: string | null;
discovered_devices?: DiscoveredDevice[];
presence_status?: string;
presence_status?: TeamSyncPresenceState;
paired_peer_count?: number;
}

Expand Down
160 changes: 160 additions & 0 deletions packages/ui/src/tabs/sync/components/team-sync-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { deriveTeamSyncPrimaryStatus } from "../view-model";
import { TeamSyncPanel } from "./team-sync-panel";

let mount: HTMLDivElement | null = null;

function renderPrimaryStatus(primaryStatus: ReturnType<typeof deriveTeamSyncPrimaryStatus>) {
mount = document.createElement("div");
document.body.appendChild(mount);
act(() => {
render(
<TeamSyncPanel
actionItems={[]}
actionableCount={0}
discoveredListMount={null}
discoveredRows={[]}
joinRequestsMount={null}
onApproveJoinRequest={async () => null}
onAttentionAction={async () => {}}
onDenyJoinRequest={async () => null}
onInspectConflict={() => {}}
onRemoveConflict={async () => null}
onReviewDiscoveredDevice={async () => null}
pendingJoinRequests={[]}
presenceStatus="posted"
primaryStatus={primaryStatus}
/>,
mount as HTMLDivElement,
);
});
return mount;
}

afterEach(() => {
if (mount) {
act(() => render(null, mount as HTMLDivElement));
mount.remove();
mount = null;
}
document.body.innerHTML = "";
});

describe("TeamSyncPanel primary status", () => {
const coordinator: NonNullable<Parameters<typeof deriveTeamSyncPrimaryStatus>[0]["coordinator"]> =
{
configured: true,
sync_enabled: true,
groups: ["Acme"],
presence_status: "posted",
};
const healthyPeer = {
peer_device_id: "peer-healthy",
status: { peer_state: "online", sync_status: "ok" },
};
const cases: Array<[string, Parameters<typeof deriveTeamSyncPrimaryStatus>[0], string, string]> =
[
[
"posted presence with sync disabled",
{
status: { enabled: false, daemon_state: "disabled" },
coordinator,
peers: [healthyPeer],
},
"Sync off",
"turn on sync",
],
[
"pending_setup",
{
status: { enabled: true, daemon_state: "ok" },
coordinator,
shareOperations: [
{ projects: [{ display_name: "Roadmap" }], lifecycle: { state: "pending_setup" } },
],
},
"Setup pending",
"Roadmap",
],
[
"owner needs_attention",
{
status: { enabled: true, daemon_state: "ok" },
coordinator,
shareOperations: [
{ projects: [{ display_name: "Roadmap" }], lifecycle: { state: "needs_attention" } },
],
},
"Needs attention",
"retry setup",
],
[
"trust pending",
{
status: { enabled: true, daemon_state: "ok" },
coordinator,
peers: [{ peer_device_id: "peer-pending", status: { peer_state: "waiting" } }],
},
"Pairing needed",
"both devices",
],
[
"enrolled and reachable only",
{ status: { enabled: true, daemon_state: "ok" }, coordinator },
"Reachable",
"Pair and approve",
],
[
"not enrolled",
{
status: { enabled: true, daemon_state: "ok" },
coordinator: { ...coordinator, presence_status: "not_enrolled" },
},
"Not enrolled",
"Paste a Team invite",
],
[
"configured unreachable",
{
status: { enabled: true, daemon_state: "ok" },
coordinator: { ...coordinator, presence_status: "unknown" },
},
"Unreachable",
"Check the coordinator connection",
],
[
"unconfigured setup",
{
status: { enabled: true, daemon_state: "ok" },
coordinator: { configured: false, sync_enabled: true, groups: [] },
},
"Setup needed",
"configure a coordinator",
],
];

it.each(cases)("renders one concrete next action for %s", (_name, input, badge, action) => {
const root = renderPrimaryStatus(deriveTeamSyncPrimaryStatus(input));

expect(root.textContent).toContain(badge);
expect(root.textContent).toContain(action);
expect(root.querySelectorAll("[data-primary-sync-state]")).toHaveLength(1);
expect(root.textContent).not.toContain("No urgent team work");
expect(root.textContent).not.toContain(deriveTeamSyncPrimaryStatus(input).meta);
});

it("reserves no-urgent copy for healthy enabled data-plane sync", () => {
const root = renderPrimaryStatus(
deriveTeamSyncPrimaryStatus({
status: { enabled: true, daemon_state: "ok", daemon_running: true },
coordinator,
peers: [healthyPeer],
}),
);

expect(root.textContent).toContain("No urgent team work right now.");
expect(root.querySelector("[data-primary-sync-state]")).toBeNull();
});
});
27 changes: 13 additions & 14 deletions packages/ui/src/tabs/sync/components/team-sync-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { ComponentChildren } from "preact";
import { createPortal } from "preact/compat";
import { useState } from "preact/hooks";
import { state } from "../../../lib/state";
import type { UiSyncAttentionItem } from "../view-model";
import type { UiSyncAttentionItem, UiTeamSyncPrimaryStatus } from "../view-model";
import type { SyncActionFeedback } from "./sync-inline-feedback";
import { SyncInlineFeedback } from "./sync-inline-feedback";

Expand Down Expand Up @@ -49,6 +49,7 @@ type TeamSyncPanelProps = {
onReviewDiscoveredDevice: (row: TeamSyncDiscoveredRow) => Promise<SyncActionFeedback | null>;
pendingJoinRequests: TeamSyncPendingJoinRequest[];
presenceStatus: string;
primaryStatus: UiTeamSyncPrimaryStatus;
};

function SectionHeading({ count, label }: { count?: number; label: string }) {
Expand Down Expand Up @@ -280,41 +281,39 @@ function ActionContent(props: TeamSyncPanelProps) {
const hasAttentionItems = props.actionItems.length > 0;
const hasOtherActionableWork = props.actionableCount > props.actionItems.length;
const showNextStepsLabel =
hasAttentionItems || hasOtherActionableWork || props.presenceStatus !== "posted";
hasAttentionItems || hasOtherActionableWork || props.primaryStatus.state !== "healthy";

return (
<>
{showNextStepsLabel ? <SectionHeading label="Needs attention" /> : null}
{showNextStepsLabel ? (
<SectionNote>Start here when something needs review, re-pairing, or approval.</SectionNote>
) : null}
{props.primaryStatus.nextAction ? (
<div className="sync-action" data-primary-sync-state={props.primaryStatus.state}>
<div className="sync-action-text">
{props.primaryStatus.badgeLabel}
<span className="sync-action-command">{props.primaryStatus.nextAction}</span>
</div>
</div>
) : null}
{hasAttentionItems
? props.actionItems.map((item) => (
<AttentionRow key={item.id} item={item} onAction={props.onAttentionAction} />
))
: null}
{!hasAttentionItems && !hasOtherActionableWork && props.presenceStatus === "posted" ? (
{!hasAttentionItems && !hasOtherActionableWork && props.primaryStatus.state === "healthy" ? (
<div className="sync-action">
<div className="sync-action-text">No urgent team work right now.</div>
</div>
) : null}
{!hasAttentionItems && hasOtherActionableWork && props.presenceStatus === "posted" ? (
{!hasAttentionItems && hasOtherActionableWork ? (
<div className="sync-action">
<div className="sync-action-text">
More team follow-up is listed below when you are ready.
</div>
</div>
) : null}
{!hasAttentionItems && props.presenceStatus === "not_enrolled" ? (
<div className="sync-action">
<div className="sync-action-text">
This device needs team enrollment
<span className="sync-action-command">
Import an invite or ask an admin to enroll it.
</span>
</div>
</div>
) : null}
</>
);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/tabs/sync/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
const originalActors = state.lastSyncActors;
const originalPeers = state.lastSyncPeers;
const originalViewModel = state.lastSyncViewModel;
const healthyPrimaryStatus = {
state: "healthy" as const,
badgeLabel: "Healthy",
meta: "Sync is healthy.",
nextAction: null,
};

afterEach(() => {
state.lastSyncActors = originalActors;
Expand Down Expand Up @@ -43,6 +49,7 @@ describe("buildActorSelectOptions", () => {
];
state.lastSyncPeers = [];
state.lastSyncViewModel = {
primaryStatus: healthyPrimaryStatus,
summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 },
attentionItems: [],
duplicatePeople: [
Expand All @@ -68,6 +75,7 @@ describe("buildActorSelectOptions", () => {
];
state.lastSyncPeers = [];
state.lastSyncViewModel = {
primaryStatus: healthyPrimaryStatus,
summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 },
attentionItems: [],
duplicatePeople: [
Expand All @@ -90,6 +98,7 @@ describe("buildActorSelectOptions", () => {
state.lastSyncActors = [{ actor_id: "actor-local", display_name: "Adam", is_local: true }];
state.lastSyncPeers = [];
state.lastSyncViewModel = {
primaryStatus: healthyPrimaryStatus,
summary: { connectedDeviceCount: 0, seenOnTeamCount: 0, offlineTeamDeviceCount: 0 },
attentionItems: [],
duplicatePeople: [],
Expand Down
19 changes: 18 additions & 1 deletion packages/ui/src/tabs/sync/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,19 @@ describe("loadSyncData", () => {
const { state } = await import("../../lib/state");
const { loadSyncData } = await import("./index");
vi.mocked(api.loadSyncStatus).mockResolvedValue({
peers: [{ peer_device_id: "peer-still-visible" }],
status: { enabled: true, daemon_state: "ok", daemon_running: true },
coordinator: {
configured: true,
sync_enabled: true,
groups: ["Acme"],
presence_status: "posted",
},
peers: [
{
peer_device_id: "peer-still-visible",
status: { peer_state: "online", sync_status: "ok" },
},
],
sharing_review: [],
attempts: [],
legacy_devices: [],
Expand All @@ -175,5 +187,10 @@ describe("loadSyncData", () => {

expect(state.lastSyncPeers.map((peer) => peer.peer_device_id)).toEqual(["peer-still-visible"]);
expect(state.shareOperationsLoadError).toBe(true);
expect(state.lastSyncViewModel?.primaryStatus).toMatchObject({
state: "needs-attention",
badgeLabel: "Refresh needed",
nextAction: expect.stringMatching(/Refresh.*retry/),
});
});
});
12 changes: 11 additions & 1 deletion packages/ui/src/tabs/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
renderTeamSync,
setLoadSyncData as setTeamSyncLoadData,
} from "./team-sync";
import { deriveSyncViewModel } from "./view-model";
import { deriveSyncViewModel, type TeamSyncReconciliationState } from "./view-model";

/* ── Re-exports consumed by app.ts ───────────────────────── */

Expand All @@ -51,6 +51,12 @@ type SyncStatusResponseLike = {
legacy_shared_review?: Record<string, unknown> | null;
attempts?: unknown[];
legacy_devices?: unknown[];
recipient_policy_reconciliation?: {
items?: Array<{
canonicalProjectIdentity?: string;
state?: TeamSyncReconciliationState;
}>;
} | null;
};

type SyncActorListResponseLike = {
Expand Down Expand Up @@ -218,6 +224,10 @@ export async function loadSyncData() {
actors: state.lastSyncActors,
peers: state.lastSyncPeers,
coordinator: state.lastSyncCoordinator,
status: statusPayload,
shareOperations: state.lastShareOperations,
Comment thread
kunickiaj marked this conversation as resolved.
shareOperationsLoadError,
reconciliation: payload.recipient_policy_reconciliation,
duplicatePersonDecisions: state.lastSyncDuplicatePersonDecisions,
});
renderSyncStatus();
Expand Down
Loading