feat(member-work-sync): show status in member details

This commit is contained in:
777genius 2026-04-29 14:54:56 +03:00
parent 98a7c915ed
commit ab459fbae0
5 changed files with 143 additions and 1 deletions

View file

@ -2,3 +2,4 @@ export * from './adapters/memberWorkSyncStatusViewModel';
export * from './hooks/useMemberWorkSyncStatus';
export * from './ui/MemberWorkSyncBadge';
export * from './ui/MemberWorkSyncDetails';
export * from './ui/MemberWorkSyncStatusPanel';

View file

@ -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 <MemberWorkSyncDetails status={status} showDiagnostics={showDiagnostics} />;
}
return (
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
{loading
? 'Loading member work sync diagnostics.'
: error
? 'Member work sync diagnostics are unavailable.'
: viewModel.tooltip}
</p>
</div>
<MemberWorkSyncBadge viewModel={viewModel} />
</div>
</section>
);
}

View file

@ -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 = ({
</TabsTrigger>
</TabsList>
<TabsContent value="tasks">
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
<div className="space-y-3">
<MemberWorkSyncStatusPanel
teamName={teamName}
memberName={member.name}
enabled={open && !member.removedAt}
/>
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
</div>
</TabsContent>
<TabsContent value="activity">
<MemberMessagesTab

View file

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MemberWorkSyncBadge,
MemberWorkSyncDetails,
MemberWorkSyncStatusPanel,
useMemberWorkSyncStatus,
} from '@features/member-work-sync/renderer';
@ -110,4 +111,26 @@ describe('member work sync renderer', () => {
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' });
});
});

View file

@ -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> = {}
): 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();