From 6e1a06f8d71d7305d8883aff444a944e83b16e3f Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Tue, 21 Apr 2026 17:38:32 -0400 Subject: [PATCH] fix(devices): show stale as distinct state on /people device column (#2629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(devices): surface stale as its own state in people table device column The /people table's DEVICE column was collapsing stale devices into "Non-Compliant" while the employee drill-in correctly showed a gray "Stale (Nd)" badge. Surface stale as a distinct fourth state in the roll-up so the two views agree. - Extend MemberDeviceStatus with 'stale'. - Roll-up precedence: non-compliant > stale > compliant (a hard fail still wins; all-stale members show stale). - MemberRow renders a gray dot + muted 'Stale' label, matching the drill-in's DS secondary-badge weight. - 10 new/updated tests on compute-device-status-map; 2 new tests on MemberRow. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(devices): add tooltip explaining Stale state on people table Next to the Stale device label, show a small Information icon that reveals a tooltip on hover: > This device's CompAI agent hasn't reported in over 7 days, so we > can't verify its current compliance. Ask the employee to update or > reinstall the agent. Discoverable cue for admins who haven't seen the state before and wouldn't otherwise know that Stale means "data is unknown, not non-compliant." Co-Authored-By: Claude Opus 4.7 (1M context) * feat(devices): consistent stale-state tooltip across device surfaces Replace native title attrs on the overall-stale badge with a shared info-icon + tooltip trigger on the device list, employee device tab, and device details header, matching the pattern already used on the people list. Copy is identical across surfaces so users see the same explanation wherever they encounter the Stale state. * fix(devices): preserve non-compliant across multiple fleet hosts per member Cubic P1/P2 feedback on PR #2629. The fleet-fallback loop in computeDeviceStatusMap had regressed to last-host-wins when I refactored for the stale state — two fleet hosts for the same member with mixed pass/fail outcomes would write whichever came last, potentially overwriting a real non-compliant with a later compliant. Restore the guard: once a member is non-compliant from a failing fleet host, subsequent hosts cannot downgrade them. Regression tests cover both iteration orders (fail-then-pass and pass-then-fail). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(devices): branch stale tooltip copy for never-reported devices --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../components/EmployeeTasks.test.tsx | 60 +++++++------ .../[employeeId]/components/EmployeeTasks.tsx | 31 +++++-- .../people/all/components/MemberRow.test.tsx | 48 ++++++++++- .../people/all/components/MemberRow.tsx | 69 +++++++++++---- .../compute-device-status-map.test.ts | 85 ++++++++++++++++++- .../components/compute-device-status-map.ts | 52 ++++++++---- .../DeviceAgentDevicesList.test.tsx | 54 ++++++++++++ .../components/DeviceAgentDevicesList.tsx | 39 ++++++--- .../devices/components/DeviceDetails.test.tsx | 41 +++++++-- .../devices/components/DeviceDetails.tsx | 32 +++++-- 10 files changed, 415 insertions(+), 96 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx index 7edce89d8..40a72f0fa 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx @@ -156,33 +156,43 @@ describe('EmployeeTasks device compliance badge', () => { expect(screen.getByText('Stale')).toBeInTheDocument(); }); - it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => { - const { rerender } = renderWithDevice( - makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }), - ); - expect(screen.getByText('Stale (9d)').closest('[title]')?.getAttribute('title')).toBe( - 'No check-in in 9 days', - ); + it('renders a stale-explainer tooltip trigger for a stale device', () => { + renderWithDevice(makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 })); + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); - rerender( - , + it('renders the stale-explainer tooltip trigger when daysSinceLastCheckIn is null (never reported)', () => { + renderWithDevice( + makeDevice({ + complianceStatus: 'stale', + daysSinceLastCheckIn: null, + lastCheckIn: null, + }), ); - expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe( - 'No check-ins recorded', + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a compliant device', () => { + renderWithDevice(makeDevice({ complianceStatus: 'compliant' })); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a non-compliant device', () => { + renderWithDevice( + makeDevice({ + complianceStatus: 'non_compliant', + isCompliant: false, + diskEncryptionEnabled: false, + }), ); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx index be68e5506..5b6529cea 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -4,6 +4,7 @@ import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import { Badge, Section, @@ -14,6 +15,7 @@ import { TabsTrigger, Text, } from '@trycompai/design-system'; +import { Information } from '@trycompai/design-system/icons'; import { AlertCircle, Award, CheckCircle2, Download, Info } from 'lucide-react'; import type { FleetPolicy, Host } from '../../devices/types'; import type { DeviceWithChecks } from '../../devices/types'; @@ -56,18 +58,35 @@ function staleLabel(daysSinceLastCheckIn: number | null): string { return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; } -function staleTitle(daysSinceLastCheckIn: number | null): string { +function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { return daysSinceLastCheckIn === null - ? 'No check-ins recorded' - : `No check-in in ${daysSinceLastCheckIn} days`; + ? "This device's CompAI agent hasn't reported any check-ins, so we can't verify its current compliance. Ask the employee to install or activate the agent." + : "This device's CompAI agent hasn't reported in over 7 days, so we can't verify its current compliance. Ask the employee to update or reinstall the agent."; } function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.complianceStatus === 'stale') { return ( - - {staleLabel(device.daysSinceLastCheckIn)} - +
+ {staleLabel(device.daysSinceLastCheckIn)} + + + + + + + {staleTooltipCopy(device.daysSinceLastCheckIn)} + + + +
); } if (device.complianceStatus === 'compliant') { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx index b0a6c75f0..9e7fff392 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx @@ -60,7 +60,9 @@ const baseMember = { const noop = vi.fn(); -function renderMemberRow(deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed') { +function renderMemberRow( + deviceStatus?: 'compliant' | 'non-compliant' | 'stale' | 'not-installed', +) { return render( @@ -109,6 +111,41 @@ describe('MemberRow device status', () => { expect(screen.getByText('Non-Compliant').className).toContain('text-foreground'); }); + it('shows "Stale" with gray dot and muted label when deviceStatus is stale', () => { + const { container } = renderMemberRow('stale'); + expect(screen.getByText('Stale')).toBeInTheDocument(); + expect(screen.getByText('Stale').className).toContain('text-muted-foreground'); + expect(container.querySelector('.bg-gray-400')).toBeInTheDocument(); + }); + + it('renders an info tooltip trigger next to the Stale label', () => { + renderMemberRow('stale'); + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); + + it('does not render the Stale info tooltip for compliant devices', () => { + renderMemberRow('compliant'); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render the Stale info tooltip for non-compliant devices', () => { + renderMemberRow('non-compliant'); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render the Stale info tooltip for not-installed devices', () => { + renderMemberRow('not-installed'); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + it('does not show device status for platform admin', () => { const adminMember = { ...baseMember, @@ -175,8 +212,13 @@ describe('MemberRow device status', () => { expect(yellowDot).toBeInTheDocument(); u2(); - const { container: c3 } = renderMemberRow('not-installed'); - const redDot = c3.querySelector('.bg-red-400'); + const { container: c3, unmount: u3 } = renderMemberRow('stale'); + const grayDot = c3.querySelector('.bg-gray-400'); + expect(grayDot).toBeInTheDocument(); + u3(); + + const { container: c4 } = renderMemberRow('not-installed'); + const redDot = c4.querySelector('.bg-red-400'); expect(redDot).toBeInTheDocument(); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 1fb8d5af1..43c505f98 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -19,6 +19,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@trycompai/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import { parseRolesString } from '@/lib/permissions'; import type { Role } from '@db'; import { @@ -33,7 +34,7 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Checkmark, Edit, Laptop, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; +import { Checkmark, Edit, Information, Laptop, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { toast } from 'sonner'; import { MultiRoleCombobox } from './MultiRoleCombobox'; @@ -52,7 +53,7 @@ interface MemberRowProps { isCurrentUserOwner: boolean; customRoles?: CustomRoleOption[]; taskCompletion?: TaskCompletion; - deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed'; + deviceStatus?: 'compliant' | 'non-compliant' | 'stale' | 'not-installed'; isDeviceStatusLoading?: boolean; } @@ -87,6 +88,34 @@ function parseRoles(role: Role | Role[] | string): string[] { return parseRolesString(role); } +type DeviceStatus = 'compliant' | 'non-compliant' | 'stale' | 'not-installed'; + +function getDeviceStatusDotClass(status: DeviceStatus): string { + switch (status) { + case 'compliant': + return 'bg-green-500'; + case 'non-compliant': + return 'bg-yellow-500'; + case 'stale': + return 'bg-gray-400'; + case 'not-installed': + return 'bg-red-400'; + } +} + +function getDeviceStatusLabel(status: DeviceStatus): string { + switch (status) { + case 'compliant': + return 'Compliant'; + case 'non-compliant': + return 'Non-Compliant'; + case 'stale': + return 'Stale'; + case 'not-installed': + return 'Not Installed'; + } +} + export function MemberRow({ member, onRemove, @@ -251,27 +280,37 @@ export function MemberRow({ ) : (
- {deviceStatus === 'compliant' - ? 'Compliant' - : deviceStatus === 'non-compliant' - ? 'Non-Compliant' - : 'Not Installed'} + {getDeviceStatusLabel(deviceStatus)} + {deviceStatus === 'stale' && ( + + + + + + + This device's CompAI agent hasn't reported in recently, so we can't verify + its current compliance. Ask the employee to update or reinstall the agent. + + + + )}
)} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts index 411512073..bcd67ac7d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts @@ -73,7 +73,7 @@ describe('computeDeviceStatusMap', () => { expect(map.mem_1).toBe('non-compliant'); }); - it('counts a stale device as non-compliant in the roll-up', () => { + it('returns stale when the only agent device is stale', () => { const map = computeDeviceStatusMap({ agentDevices: [ makeAgentDevice({ @@ -86,10 +86,10 @@ describe('computeDeviceStatusMap', () => { fleetHosts: [], complianceMemberIds: ['mem_1'], }); - expect(map.mem_1).toBe('non-compliant'); + expect(map.mem_1).toBe('stale'); }); - it('counts stale among mixed devices as non-compliant', () => { + it('returns stale when a compliant and a stale device are mixed', () => { const map = computeDeviceStatusMap({ agentDevices: [ makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'compliant' }), @@ -102,9 +102,45 @@ describe('computeDeviceStatusMap', () => { fleetHosts: [], complianceMemberIds: ['mem_1'], }); + expect(map.mem_1).toBe('stale'); + }); + + it('prefers non-compliant over stale when both present', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 10, + }), + makeAgentDevice({ memberId: 'mem_1', complianceStatus: 'non_compliant' }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); expect(map.mem_1).toBe('non-compliant'); }); + it('returns stale when all devices are stale', () => { + const map = computeDeviceStatusMap({ + agentDevices: [ + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 10, + }), + makeAgentDevice({ + memberId: 'mem_1', + complianceStatus: 'stale', + daysSinceLastCheckIn: 20, + }), + ], + fleetHosts: [], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('stale'); + }); + it('falls back to Fleet policy status when no agent device is present', () => { const map = computeDeviceStatusMap({ agentDevices: [], @@ -139,7 +175,48 @@ describe('computeDeviceStatusMap', () => { ], complianceMemberIds: ['mem_1'], }); - // Agent says stale → non-compliant, even if fleet would say compliant. + // Agent says stale → member is stale, even if fleet would say compliant. + expect(map.mem_1).toBe('stale'); + }); + + it('keeps a member non-compliant when a failing fleet host precedes a passing one', () => { + // Regression test for a fleet-loop last-host-wins bug: a passing host + // encountered after a failing one for the same member must not overwrite + // non-compliant with compliant. + const map = computeDeviceStatusMap({ + agentDevices: [], + fleetHosts: [ + makeFleetHost({ + member_id: 'mem_1', + policies: [{ id: 1, name: 'Disk Enc', response: 'fail' }], + }), + makeFleetHost({ + member_id: 'mem_1', + policies: [{ id: 2, name: 'Antivirus', response: 'pass' }], + }), + ], + complianceMemberIds: ['mem_1'], + }); + expect(map.mem_1).toBe('non-compliant'); + }); + + it('keeps a member non-compliant when a passing fleet host precedes a failing one', () => { + // Sibling order — the passing host runs first; the later failing host + // must still flip the member to non-compliant. + const map = computeDeviceStatusMap({ + agentDevices: [], + fleetHosts: [ + makeFleetHost({ + member_id: 'mem_1', + policies: [{ id: 1, name: 'Antivirus', response: 'pass' }], + }), + makeFleetHost({ + member_id: 'mem_1', + policies: [{ id: 2, name: 'Disk Enc', response: 'fail' }], + }), + ], + complianceMemberIds: ['mem_1'], + }); expect(map.mem_1).toBe('non-compliant'); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts index 629ad4745..a78a5282b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.ts @@ -1,17 +1,22 @@ import type { DeviceWithChecks, Host } from '../../devices/types'; -export type MemberDeviceStatus = 'compliant' | 'non-compliant' | 'not-installed'; +export type MemberDeviceStatus = 'compliant' | 'non-compliant' | 'stale' | 'not-installed'; /** * Roll-up per-member device compliance for the People table. * - * Rules (in order): + * Rules (in order of precedence): * 1. Every member in `complianceMemberIds` starts as `not-installed`. - * 2. For each agent device with a memberId in the set, ALL of that member's - * devices must have `complianceStatus === 'compliant'` to roll up to - * `compliant`. `non_compliant` and `stale` both count as non-compliant. + * 2. For each agent device with a memberId in the set, the member's roll-up is + * `non-compliant` > `stale` > `compliant`: + * - Any device with `complianceStatus === 'non_compliant'` → member is + * `'non-compliant'`. + * - Else any device with `complianceStatus === 'stale'` → member is + * `'stale'`. + * - Else (all devices compliant) → `'compliant'`. * 3. If a member has no agent device but has a Fleet host, we fall back to - * Fleet policy status. Agent data always wins when present. + * Fleet policy status (compliant / non-compliant; Fleet has no stale + * concept). Agent data always wins when present. */ export function computeDeviceStatusMap({ agentDevices, @@ -28,25 +33,38 @@ export function computeDeviceStatusMap({ map[id] = 'not-installed'; } - const agentComplianceByMember = new Map(); + const agentRollup = new Map(); for (const d of agentDevices) { if (!d.memberId || !complianceSet.has(d.memberId)) continue; - const prev = agentComplianceByMember.get(d.memberId); - // Stale devices count as non-compliant for the roll-up. - const isCompliant = d.complianceStatus === 'compliant'; - agentComplianceByMember.set(d.memberId, (prev ?? true) && isCompliant); + + const prev = agentRollup.get(d.memberId); + // Once a member has a non-compliant device, nothing can downgrade it. + if (prev === 'non-compliant') continue; + + if (d.complianceStatus === 'non_compliant') { + agentRollup.set(d.memberId, 'non-compliant'); + continue; + } + if (d.complianceStatus === 'stale') { + // Stale wins over compliant but loses to non-compliant. + if (prev !== 'stale') agentRollup.set(d.memberId, 'stale'); + continue; + } + // complianceStatus === 'compliant' (or any other benign value). + if (prev === undefined) agentRollup.set(d.memberId, 'compliant'); } - for (const [memberId, allCompliant] of agentComplianceByMember) { - map[memberId] = allCompliant ? 'compliant' : 'non-compliant'; + for (const [memberId, status] of agentRollup) { + map[memberId] = status; } for (const host of fleetHosts) { if (!host.member_id || !complianceSet.has(host.member_id)) continue; - if (agentComplianceByMember.has(host.member_id)) continue; + if (agentRollup.has(host.member_id)) continue; + // Non-compliant wins across multiple Fleet hosts for the same member — + // once we've seen a failing host, a later passing host must not clobber it. + if (map[host.member_id] === 'non-compliant') continue; const isCompliant = host.policies.every((p) => p.response === 'pass'); - if (map[host.member_id] !== 'non-compliant') { - map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; - } + map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant'; } return map; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index dd948905c..8cec5285d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -123,4 +123,58 @@ describe('DeviceAgentDevicesList', () => { expect(contents).toContain('Beta'); expect(contents).toContain('stale'); }); + + it('renders a stale-explainer tooltip trigger next to the Stale badge', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); + + it('renders the stale-explainer tooltip trigger when daysSinceLastCheckIn is null (never reported)', () => { + render( + , + ); + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a compliant device', () => { + render(); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a non-compliant device', () => { + render( + , + ); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index 0b9c9861b..2b89923f0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -19,7 +19,8 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Download, Search } from '@trycompai/design-system/icons'; +import { Download, Information, Search } from '@trycompai/design-system/icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; @@ -72,6 +73,12 @@ function staleLabel(daysSinceLastCheckIn: number | null): string { return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; } +function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null + ? "This device's CompAI agent hasn't reported any check-ins, so we can't verify its current compliance. Ask the employee to install or activate the agent." + : "This device's CompAI agent hasn't reported in over 7 days, so we can't verify its current compliance. Ask the employee to update or reinstall the agent."; +} + function UserNameCell({ device, orgId }: { device: DeviceWithChecks; orgId: string }) { const memberId = device.memberId; @@ -101,16 +108,26 @@ function UserNameCell({ device, orgId }: { device: DeviceWithChecks; orgId: stri function CompliantBadge({ device }: { device: DeviceWithChecks }) { if (device.complianceStatus === 'stale') { return ( - - {staleLabel(device.daysSinceLastCheckIn)} - +
+ {staleLabel(device.daysSinceLastCheckIn)} + + + + + + + {staleTooltipCopy(device.daysSinceLastCheckIn)} + + + +
); } if (device.complianceStatus === 'compliant') { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx index f79c8a674..2dbde9cc5 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx @@ -85,18 +85,20 @@ describe('DeviceDetails compliance badge', () => { expect(screen.getByText('Stale')).toBeInTheDocument(); }); - it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => { - const { rerender } = render( + it('renders a stale-explainer tooltip trigger for a stale device', () => { + render( , ); - expect(screen.getByText('Stale (30d)').closest('[title]')?.getAttribute('title')).toBe( - 'No check-in in 30 days', - ); + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); - rerender( + it('renders the stale-explainer tooltip trigger when daysSinceLastCheckIn is null (never reported)', () => { + render( { onClose={vi.fn()} />, ); - expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe( - 'No check-ins recorded', + expect( + screen.getByRole('button', { name: /What does Stale mean\?/i }), + ).toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a compliant device', () => { + render(); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); + }); + + it('does not render the stale-explainer tooltip trigger for a non-compliant device', () => { + render( + , ); + expect( + screen.queryByRole('button', { name: /What does Stale mean\?/i }), + ).not.toBeInTheDocument(); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index af5b350f8..9b2f1ce95 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -15,7 +15,8 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { ArrowLeft } from '@trycompai/design-system/icons'; +import { ArrowLeft, Information } from '@trycompai/design-system/icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import type { DeviceWithChecks } from '../types'; const CHECK_FIELDS = [ @@ -42,18 +43,35 @@ function staleLabel(daysSinceLastCheckIn: number | null): string { return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; } -function staleTitle(daysSinceLastCheckIn: number | null): string { +function staleTooltipCopy(daysSinceLastCheckIn: number | null): string { return daysSinceLastCheckIn === null - ? 'No check-ins recorded' - : `No check-in in ${daysSinceLastCheckIn} days`; + ? "This device's CompAI agent hasn't reported any check-ins, so we can't verify its current compliance. Ask the employee to install or activate the agent." + : "This device's CompAI agent hasn't reported in over 7 days, so we can't verify its current compliance. Ask the employee to update or reinstall the agent."; } function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.complianceStatus === 'stale') { return ( - - {staleLabel(device.daysSinceLastCheckIn)} - +
+ {staleLabel(device.daysSinceLastCheckIn)} + + + + + + + {staleTooltipCopy(device.daysSinceLastCheckIn)} + + + +
); } if (device.complianceStatus === 'compliant') {