diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts index 60559156..98f250ce 100644 --- a/src/features/member-work-sync/renderer/index.ts +++ b/src/features/member-work-sync/renderer/index.ts @@ -2,3 +2,4 @@ export * from './adapters/memberWorkSyncStatusViewModel'; export * from './hooks/useMemberWorkSyncStatus'; export * from './ui/MemberWorkSyncBadge'; export * from './ui/MemberWorkSyncDetails'; +export * from './ui/MemberWorkSyncStatusPanel'; diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx new file mode 100644 index 00000000..6a4d1392 --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx @@ -0,0 +1,51 @@ +import type React from 'react'; + +import { MemberWorkSyncBadge } from './MemberWorkSyncBadge'; +import { MemberWorkSyncDetails } from './MemberWorkSyncDetails'; +import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus'; + +interface MemberWorkSyncStatusPanelProps { + teamName: string; + memberName: string; + enabled?: boolean; + showDiagnostics?: boolean; +} + +export function MemberWorkSyncStatusPanel({ + teamName, + memberName, + enabled = true, + showDiagnostics = false, +}: MemberWorkSyncStatusPanelProps): React.ReactElement | null { + const { status, viewModel, loading, error } = useMemberWorkSyncStatus({ + teamName, + memberName, + enabled, + }); + + if (!enabled) { + return null; + } + + if (status) { + return ; + } + + return ( + + + + Member work sync + + {loading + ? 'Loading member work sync diagnostics.' + : error + ? 'Member work sync diagnostics are unavailable.' + : viewModel.tooltip} + + + + + + ); +} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 3828f04c..1b3c5166 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u import { useMemberStats } from '@renderer/hooks/useMemberStats'; import { useStore } from '@renderer/store'; import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice'; +import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer'; import { buildMemberLaunchDiagnosticsPayload, getMemberLaunchDiagnosticsErrorMessage, @@ -291,7 +292,14 @@ export const MemberDetailDialog = ({ - + + + + { expect(host.textContent).toContain('11111111'); expect(host.textContent).not.toContain('developer_only'); }); + + it('renders the status panel through the read-only API hook', async () => { + apiMocks.getStatus.mockResolvedValue(makeStatus()); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberWorkSyncStatusPanel, { + teamName: 'team-a', + memberName: 'bob', + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Member work sync'); + expect(host.textContent).toContain('Needs sync'); + expect(host.textContent).toContain('Shadow would nudge'); + expect(apiMocks.getStatus).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'bob' }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index ff4b0be1..fb6dcdd2 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -4,8 +4,59 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useStore } from '@renderer/store'; +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +const apiMocks = vi.hoisted(() => ({ + getMemberWorkSyncStatus: vi.fn(), +})); + +function makeMemberWorkSyncStatus( + overrides: Partial = {} +): MemberWorkSyncStatus { + return { + teamName: 'demo-team', + memberName: 'jack', + state: 'needs_sync', + agenda: { + teamName: 'demo-team', + memberName: 'jack', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:abcdef123456', + items: [ + { + taskId: 'task-1', + displayId: '11111111', + subject: 'Review patch', + kind: 'work', + assignee: 'jack', + priority: 'normal', + reason: 'owned_pending_task', + evidence: { status: 'in_progress', owner: 'jack' }, + }, + ], + diagnostics: [], + }, + shadow: { + reconciledBy: 'request', + wouldNudge: true, + fingerprintChanged: false, + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} + +vi.mock('@renderer/api', () => ({ + api: { + memberWorkSync: { + getStatus: apiMocks.getMemberWorkSyncStatus, + }, + }, + isElectronMode: () => true, +})); + vi.mock('@renderer/hooks/useMemberStats', () => ({ useMemberStats: () => ({ stats: null, @@ -115,6 +166,7 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta describe('MemberDetailDialog activity count', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus()); useStore.setState({ teamMessagesByName: { 'demo-team': { @@ -134,6 +186,7 @@ describe('MemberDetailDialog activity count', () => { afterEach(() => { document.body.innerHTML = ''; + vi.clearAllMocks(); useStore.setState({ teamMessagesByName: {} } as never); vi.unstubAllGlobals(); }); @@ -202,6 +255,12 @@ describe('MemberDetailDialog activity count', () => { expect(host.textContent).toContain('activity-count:1'); expect(host.textContent).toContain('Activity1'); + expect(host.textContent).toContain('Member work sync'); + expect(host.textContent).toContain('Needs sync'); + expect(apiMocks.getMemberWorkSyncStatus).toHaveBeenCalledWith({ + teamName: 'demo-team', + memberName: 'jack', + }); await act(async () => { root.unmount();
+ {loading + ? 'Loading member work sync diagnostics.' + : error + ? 'Member work sync diagnostics are unavailable.' + : viewModel.tooltip} +