feat(team): refine review workflow indicators
This commit is contained in:
parent
bceef9dec5
commit
92e84c8461
16 changed files with 805 additions and 106 deletions
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.24",
|
"version": "0.0.25",
|
||||||
"sourceRef": "v0.0.24",
|
"sourceRef": "v0.0.25",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/agent-teams-ai",
|
"releaseRepository": "777genius/agent-teams-ai",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.24.tar.gz",
|
"file": "agent-teams-runtime-darwin-arm64-v0.0.25.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"darwin-x64": {
|
||||||
"file": "agent-teams-runtime-darwin-x64-v0.0.24.tar.gz",
|
"file": "agent-teams-runtime-darwin-x64-v0.0.25.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"linux-x64": {
|
||||||
"file": "agent-teams-runtime-linux-x64-v0.0.24.tar.gz",
|
"file": "agent-teams-runtime-linux-x64-v0.0.25.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.24.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.25.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,13 @@ export function decideMemberWorkSyncNudgeActivation(input: {
|
||||||
return { active: false, reason: 'status_not_nudgeable' };
|
return { active: false, reason: 'status_not_nudgeable' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.metrics.phase2Readiness.state === 'collecting_shadow_data' &&
|
||||||
|
isReviewPickupRequiredCandidate(input.status)
|
||||||
|
) {
|
||||||
|
return { active: true, reason: 'review_pickup_required' };
|
||||||
|
}
|
||||||
|
|
||||||
if (hasBlockingMetrics(input.metrics)) {
|
if (hasBlockingMetrics(input.metrics)) {
|
||||||
return { active: false, reason: 'blocking_metrics' };
|
return { active: false, reason: 'blocking_metrics' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,18 @@ function isTaskChangeDiagnosticCode(value: unknown): value is TaskChangeReviewDi
|
||||||
return typeof value === 'string' && TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(value);
|
return typeof value === 'string' && TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskChangeDiagnosticSeverity(
|
||||||
|
value: unknown
|
||||||
|
): value is TaskChangeReviewDiagnostic['severity'] {
|
||||||
|
return value === 'info' || value === 'warning' || value === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskChangeDiagnosticSource(
|
||||||
|
value: unknown
|
||||||
|
): value is NonNullable<TaskChangeReviewDiagnostic['source']> {
|
||||||
|
return value === 'ledger' || value === 'legacy' || value === 'summary' || value === 'runtime';
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIsoString(value: unknown): string | null {
|
function normalizeIsoString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|
@ -47,30 +59,47 @@ function normalizeFileSummary(value: unknown): FileChangeSummary | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReviewDiagnostic(value: unknown): TaskChangeReviewDiagnostic | null {
|
function normalizeReviewDiagnostic(value: unknown): TaskChangeReviewDiagnostic | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const message = value.trim();
|
||||||
|
return message
|
||||||
|
? {
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message,
|
||||||
|
source: 'legacy',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!value || typeof value !== 'object') return null;
|
if (!value || typeof value !== 'object') return null;
|
||||||
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
|
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
|
||||||
if (
|
const message = typeof candidate.message === 'string' ? candidate.message.trim() : '';
|
||||||
!isTaskChangeDiagnosticCode(candidate.code) ||
|
if (!message) {
|
||||||
(candidate.severity !== 'info' &&
|
|
||||||
candidate.severity !== 'warning' &&
|
|
||||||
candidate.severity !== 'error') ||
|
|
||||||
typeof candidate.reviewBlocking !== 'boolean' ||
|
|
||||||
typeof candidate.message !== 'string'
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const source = isTaskChangeDiagnosticSource(candidate.source) ? { source: candidate.source } : {};
|
||||||
|
if (
|
||||||
|
isTaskChangeDiagnosticCode(candidate.code) &&
|
||||||
|
isTaskChangeDiagnosticSeverity(candidate.severity) &&
|
||||||
|
typeof candidate.reviewBlocking === 'boolean'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
code: candidate.code,
|
||||||
|
severity: candidate.severity,
|
||||||
|
reviewBlocking: candidate.reviewBlocking,
|
||||||
|
message,
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: candidate.code,
|
code: 'legacy_warning',
|
||||||
severity: candidate.severity,
|
severity: 'warning',
|
||||||
reviewBlocking: candidate.reviewBlocking,
|
reviewBlocking: true,
|
||||||
message: candidate.message,
|
message,
|
||||||
...(candidate.source === 'ledger' ||
|
...source,
|
||||||
candidate.source === 'legacy' ||
|
|
||||||
candidate.source === 'summary' ||
|
|
||||||
candidate.source === 'runtime'
|
|
||||||
? { source: candidate.source }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
120
src/renderer/components/team/UnreadCommentsBadge.test.tsx
Normal file
120
src/renderer/components/team/UnreadCommentsBadge.test.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(React.Fragment, null, children),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement('div', null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||||
|
|
||||||
|
import { UnreadCommentsBadge } from './UnreadCommentsBadge';
|
||||||
|
|
||||||
|
async function flushReact(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UnreadCommentsBadge', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the comment pulse class only when pulseKey is positive', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(UnreadCommentsBadge, { unreadCount: 1, totalCount: 2 }));
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(UnreadCommentsBadge, {
|
||||||
|
unreadCount: 1,
|
||||||
|
totalCount: 2,
|
||||||
|
pulseKey: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstPulse = host.querySelector('.kanban-comment-badge-pulse');
|
||||||
|
expect(firstPulse).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(UnreadCommentsBadge, {
|
||||||
|
unreadCount: 1,
|
||||||
|
totalCount: 2,
|
||||||
|
pulseKey: 2,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBe(firstPulse);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(UnreadCommentsBadge, {
|
||||||
|
unreadCount: 1,
|
||||||
|
totalCount: 2,
|
||||||
|
pulseKey: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can appear with a pulse after rendering no badge', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(React.createElement(UnreadCommentsBadge, { unreadCount: 0, totalCount: 0 }));
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(UnreadCommentsBadge, {
|
||||||
|
unreadCount: 1,
|
||||||
|
totalCount: 1,
|
||||||
|
pulseKey: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,27 +4,38 @@ import { MessageSquare } from 'lucide-react';
|
||||||
interface UnreadCommentsBadgeProps {
|
interface UnreadCommentsBadgeProps {
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
pulseKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UnreadCommentsBadge = ({
|
export const UnreadCommentsBadge = ({
|
||||||
unreadCount,
|
unreadCount,
|
||||||
totalCount,
|
totalCount,
|
||||||
|
pulseKey,
|
||||||
}: UnreadCommentsBadgeProps): React.JSX.Element | null => {
|
}: UnreadCommentsBadgeProps): React.JSX.Element | null => {
|
||||||
if (totalCount === 0) return null;
|
if (totalCount === 0) return null;
|
||||||
|
|
||||||
|
const shouldPulse = (pulseKey ?? 0) > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="relative inline-flex size-6 shrink-0 items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)]">
|
<span className="relative inline-flex size-6 shrink-0 items-center justify-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text)]">
|
||||||
<MessageSquare size={13} />
|
<span
|
||||||
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-slate-200 px-0.5 text-[7px] font-bold leading-none text-slate-700 dark:bg-slate-200 dark:text-slate-900">
|
key={shouldPulse ? pulseKey : 'idle'}
|
||||||
{totalCount}
|
className={`relative inline-flex size-6 items-center justify-center ${
|
||||||
</span>
|
shouldPulse ? 'kanban-comment-badge-pulse' : ''
|
||||||
{unreadCount > 0 ? (
|
}`}
|
||||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-blue-500 px-1 text-[8px] font-bold leading-none text-white shadow-sm">
|
>
|
||||||
{unreadCount}
|
<MessageSquare size={13} />
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 min-w-3 items-center justify-center rounded-full bg-slate-200 px-0.5 text-[7px] font-bold leading-none text-slate-700 dark:bg-slate-200 dark:text-slate-900">
|
||||||
|
{totalCount}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
{unreadCount > 0 ? (
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-blue-500 px-1 text-[8px] font-bold leading-none text-white shadow-sm">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,30 @@
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- Component mocks mirror PascalCase exports. */
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const unreadBadgeMock = vi.hoisted(() => ({
|
||||||
|
props: [] as { unreadCount: number; totalCount: number; pulseKey?: number }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const unreadCommentCountMock = vi.hoisted(() => ({
|
||||||
|
value: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/MemberBadge', () => ({
|
vi.mock('@renderer/components/team/MemberBadge', () => ({
|
||||||
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
|
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({
|
vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({
|
||||||
UnreadCommentsBadge: () => null,
|
UnreadCommentsBadge: (props: { unreadCount: number; totalCount: number; pulseKey?: number }) => {
|
||||||
|
unreadBadgeMock.props.push(props);
|
||||||
|
return React.createElement('span', {
|
||||||
|
className: (props.pulseKey ?? 0) > 0 ? 'kanban-comment-badge-pulse' : '',
|
||||||
|
'data-pulse-key': props.pulseKey ?? 0,
|
||||||
|
'data-testid': 'unread-comments-badge',
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/button', () => ({
|
vi.mock('@renderer/components/ui/button', () => ({
|
||||||
|
|
@ -55,12 +71,14 @@ vi.mock('@renderer/hooks/useTheme', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({
|
vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({
|
||||||
useUnreadCommentCount: () => 0,
|
useUnreadCommentCount: () => unreadCommentCountMock.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention -- Re-enable after component mocks. */
|
||||||
|
|
||||||
import { KanbanTaskCard } from './KanbanTaskCard';
|
import { KanbanTaskCard } from './KanbanTaskCard';
|
||||||
|
|
||||||
import type { TeamTaskWithKanban } from '@shared/types/team';
|
import type { TaskComment, TeamTaskWithKanban } from '@shared/types/team';
|
||||||
|
|
||||||
const baseTask: TeamTaskWithKanban = {
|
const baseTask: TeamTaskWithKanban = {
|
||||||
id: 'task-1',
|
id: 'task-1',
|
||||||
|
|
@ -81,6 +99,97 @@ const baseTask: TeamTaskWithKanban = {
|
||||||
|
|
||||||
const noop = (): void => undefined;
|
const noop = (): void => undefined;
|
||||||
|
|
||||||
|
function createComment(id: string, author = 'teammate'): TaskComment {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
author,
|
||||||
|
text: `Comment ${id}`,
|
||||||
|
createdAt: '2026-04-18T10:20:00.000Z',
|
||||||
|
type: 'regular',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTaskCardElement(
|
||||||
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
|
): React.ReactElement {
|
||||||
|
return React.createElement(KanbanTaskCard, {
|
||||||
|
task: baseTask,
|
||||||
|
teamName: 'my-team',
|
||||||
|
columnId: 'in_progress',
|
||||||
|
hasReviewers: true,
|
||||||
|
compact: false,
|
||||||
|
taskMap: new Map(),
|
||||||
|
memberColorMap: new Map([['alice', 'blue']]),
|
||||||
|
onRequestReview: noop,
|
||||||
|
onApprove: noop,
|
||||||
|
onRequestChanges: noop,
|
||||||
|
onMoveBackToDone: noop,
|
||||||
|
onStartTask: noop,
|
||||||
|
onCompleteTask: noop,
|
||||||
|
onCancelTask: noop,
|
||||||
|
onViewChanges: noop,
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastUnreadBadgeProps(): { unreadCount: number; totalCount: number; pulseKey?: number } {
|
||||||
|
const props = unreadBadgeMock.props[unreadBadgeMock.props.length - 1];
|
||||||
|
if (!props) throw new Error('UnreadCommentsBadge was not rendered');
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushReact(): Promise<void> {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rerenderTaskCard(
|
||||||
|
root: ReturnType<typeof createRoot>,
|
||||||
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createTaskCardElement(props));
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStrictTaskCardElement(
|
||||||
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
|
): React.ReactElement {
|
||||||
|
return React.createElement(React.StrictMode, null, createTaskCardElement(props));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderStrictTaskCard(
|
||||||
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
|
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createStrictTaskCardElement(props));
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { host, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rerenderStrictTaskCard(
|
||||||
|
root: ReturnType<typeof createRoot>,
|
||||||
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(createStrictTaskCardElement(props));
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
unreadCommentCountMock.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
async function renderTaskCard(
|
async function renderTaskCard(
|
||||||
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
|
||||||
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
|
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
|
||||||
|
|
@ -90,32 +199,191 @@ async function renderTaskCard(
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(createTaskCardElement(props));
|
||||||
React.createElement(KanbanTaskCard, {
|
await flushReact();
|
||||||
task: baseTask,
|
|
||||||
teamName: 'my-team',
|
|
||||||
columnId: 'in_progress',
|
|
||||||
hasReviewers: true,
|
|
||||||
compact: false,
|
|
||||||
taskMap: new Map(),
|
|
||||||
memberColorMap: new Map([['alice', 'blue']]),
|
|
||||||
onRequestReview: noop,
|
|
||||||
onApprove: noop,
|
|
||||||
onRequestChanges: noop,
|
|
||||||
onMoveBackToDone: noop,
|
|
||||||
onStartTask: noop,
|
|
||||||
onCompleteTask: noop,
|
|
||||||
onCancelTask: noop,
|
|
||||||
onViewChanges: noop,
|
|
||||||
...props,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { host, root };
|
return { host, root };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('KanbanTaskCard comment badge pulse', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pulse on initial render with existing comments', async () => {
|
||||||
|
const { host, root } = await renderTaskCard({
|
||||||
|
task: { ...baseTask, comments: [createComment('comment-1')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulses when a new non-user comment arrives', async () => {
|
||||||
|
const firstComment = createComment('comment-1');
|
||||||
|
const { host, root } = await renderTaskCard({
|
||||||
|
task: { ...baseTask, comments: [firstComment] },
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, createComment('comment-2')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pulses when the first non-user comment arrives', async () => {
|
||||||
|
const { host, root } = await renderTaskCard({
|
||||||
|
task: { ...baseTask, comments: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [createComment('comment-1')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-pulse under React StrictMode', async () => {
|
||||||
|
const firstComment = createComment('comment-1');
|
||||||
|
const { root } = await renderStrictTaskCard({
|
||||||
|
task: { ...baseTask, comments: [firstComment] },
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderStrictTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, createComment('comment-2')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts the pulse when another non-user comment arrives', async () => {
|
||||||
|
const firstComment = createComment('comment-1');
|
||||||
|
const secondComment = createComment('comment-2');
|
||||||
|
const { root } = await renderTaskCard({
|
||||||
|
task: { ...baseTask, comments: [firstComment] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, secondComment] },
|
||||||
|
});
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: {
|
||||||
|
...baseTask,
|
||||||
|
comments: [firstComment, secondComment, createComment('comment-3')],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pulse when the new comment belongs to the user', async () => {
|
||||||
|
const firstComment = createComment('comment-1');
|
||||||
|
const { host, root } = await renderTaskCard({
|
||||||
|
task: { ...baseTask, comments: [firstComment] },
|
||||||
|
});
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, createComment('comment-2', 'user')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
|
||||||
|
expect(host.querySelector('.kanban-comment-badge-pulse')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pulse when only the unread count changes', async () => {
|
||||||
|
const taskWithComment = { ...baseTask, comments: [createComment('comment-1')] };
|
||||||
|
const { root } = await renderTaskCard({ task: taskWithComment });
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
unreadCommentCountMock.value = 1;
|
||||||
|
await rerenderTaskCard(root, { task: taskWithComment });
|
||||||
|
|
||||||
|
const props = getLastUnreadBadgeProps();
|
||||||
|
expect(props.unreadCount).toBe(1);
|
||||||
|
expect(props.pulseKey ?? 0).toBe(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse an old pulse when the card instance switches tasks', async () => {
|
||||||
|
const firstComment = createComment('comment-1');
|
||||||
|
const secondComment = createComment('comment-2');
|
||||||
|
const taskWithPulse = { ...baseTask, comments: [firstComment] };
|
||||||
|
const { root } = await renderTaskCard({ task: taskWithPulse });
|
||||||
|
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, secondComment] },
|
||||||
|
});
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey).toBe(1);
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: {
|
||||||
|
...baseTask,
|
||||||
|
id: 'task-2',
|
||||||
|
displayId: 'efgh5678',
|
||||||
|
comments: [createComment('task-2-comment')],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
|
||||||
|
|
||||||
|
unreadBadgeMock.props.length = 0;
|
||||||
|
await rerenderTaskCard(root, {
|
||||||
|
task: { ...baseTask, comments: [firstComment, secondComment] },
|
||||||
|
});
|
||||||
|
expect(getLastUnreadBadgeProps().pulseKey ?? 0).toBe(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushReact();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('KanbanTaskCard change badge', () => {
|
describe('KanbanTaskCard change badge', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
|
@ -32,7 +32,13 @@ import {
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
import type {
|
||||||
|
KanbanColumnId,
|
||||||
|
KanbanTaskState,
|
||||||
|
TaskComment,
|
||||||
|
TeamTask,
|
||||||
|
TeamTaskWithKanban,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
interface KanbanTaskCardProps {
|
interface KanbanTaskCardProps {
|
||||||
task: TeamTaskWithKanban;
|
task: TeamTaskWithKanban;
|
||||||
|
|
@ -63,6 +69,65 @@ interface DependencyBadgeProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentPulseState {
|
||||||
|
taskKey: string;
|
||||||
|
commentCount: number;
|
||||||
|
commentIds: Set<string>;
|
||||||
|
pulseKey: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentPulseSyncAction {
|
||||||
|
taskKey: string;
|
||||||
|
comments: readonly TaskComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TASK_COMMENTS: readonly TaskComment[] = [];
|
||||||
|
|
||||||
|
function createCommentPulseState(
|
||||||
|
taskKey: string,
|
||||||
|
comments: readonly TaskComment[],
|
||||||
|
pulseKey = 0
|
||||||
|
): CommentPulseState {
|
||||||
|
return {
|
||||||
|
taskKey,
|
||||||
|
commentCount: comments.length,
|
||||||
|
commentIds: new Set(comments.map((comment) => comment.id)),
|
||||||
|
pulseKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSameCommentIds(state: CommentPulseState, comments: readonly TaskComment[]): boolean {
|
||||||
|
return (
|
||||||
|
comments.length === state.commentCount &&
|
||||||
|
comments.every((comment) => state.commentIds.has(comment.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCommentPulseState(
|
||||||
|
state: CommentPulseState,
|
||||||
|
action: CommentPulseSyncAction
|
||||||
|
): CommentPulseState {
|
||||||
|
if (state.taskKey !== action.taskKey) {
|
||||||
|
return createCommentPulseState(action.taskKey, action.comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasNewIncomingComment =
|
||||||
|
action.comments.length > state.commentCount &&
|
||||||
|
action.comments.some(
|
||||||
|
(comment) => !state.commentIds.has(comment.id) && comment.author !== 'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasNewIncomingComment && hasSameCommentIds(state, action.comments)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createCommentPulseState(
|
||||||
|
action.taskKey,
|
||||||
|
action.comments,
|
||||||
|
hasNewIncomingComment ? state.pulseKey + 1 : state.pulseKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DependencyBadge = ({
|
const DependencyBadge = ({
|
||||||
taskId,
|
taskId,
|
||||||
taskMap,
|
taskMap,
|
||||||
|
|
@ -248,6 +313,16 @@ export const KanbanTaskCard = memo(
|
||||||
}: KanbanTaskCardProps): React.JSX.Element {
|
}: KanbanTaskCardProps): React.JSX.Element {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||||
|
const commentPulseTaskKey = `${teamName}/${task.id}`;
|
||||||
|
const comments = task.comments ?? EMPTY_TASK_COMMENTS;
|
||||||
|
const commentCount = comments.length;
|
||||||
|
const [commentPulse, syncCommentPulse] = useReducer(
|
||||||
|
syncCommentPulseState,
|
||||||
|
{ taskKey: commentPulseTaskKey, comments },
|
||||||
|
({ taskKey, comments: initialComments }) => createCommentPulseState(taskKey, initialComments)
|
||||||
|
);
|
||||||
|
const visibleCommentPulseKey =
|
||||||
|
commentPulse.taskKey === commentPulseTaskKey ? commentPulse.pulseKey : 0;
|
||||||
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedByIds = task.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
const blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
|
||||||
const hasBlockedBy = blockedByIds.length > 0;
|
const hasBlockedBy = blockedByIds.length > 0;
|
||||||
|
|
@ -267,6 +342,11 @@ export const KanbanTaskCard = memo(
|
||||||
canDisplay &&
|
canDisplay &&
|
||||||
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention');
|
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention');
|
||||||
const changesNeedAttention = task.changePresence === 'needs_attention';
|
const changesNeedAttention = task.changePresence === 'needs_attention';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncCommentPulse({ taskKey: commentPulseTaskKey, comments });
|
||||||
|
}, [commentCount, commentPulseTaskKey, comments]);
|
||||||
|
|
||||||
const metaActions = (
|
const metaActions = (
|
||||||
<>
|
<>
|
||||||
{canOpenChanges ? (
|
{canOpenChanges ? (
|
||||||
|
|
@ -285,7 +365,11 @@ export const KanbanTaskCard = memo(
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
<UnreadCommentsBadge
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
totalCount={commentCount}
|
||||||
|
pulseKey={visibleCommentPulseKey}
|
||||||
|
/>
|
||||||
{onDeleteTask ? (
|
{onDeleteTask ? (
|
||||||
<TaskActionIconButton
|
<TaskActionIconButton
|
||||||
label="Delete task"
|
label="Delete task"
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,32 @@
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes kanban-comment-badge-jelly {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
24% {
|
||||||
|
transform: translateY(-5px) scale(1.16, 0.88);
|
||||||
|
}
|
||||||
|
44% {
|
||||||
|
transform: translateY(-3px) scale(0.92, 1.1);
|
||||||
|
}
|
||||||
|
64% {
|
||||||
|
transform: translateY(-1px) scale(1.06, 0.96);
|
||||||
|
}
|
||||||
|
82% {
|
||||||
|
transform: translateY(0) scale(0.98, 1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-comment-badge-pulse {
|
||||||
|
animation: kanban-comment-badge-jelly 560ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
.kanban-grid-resize-handle-n,
|
.kanban-grid-resize-handle-n,
|
||||||
.kanban-grid-resize-handle-s {
|
.kanban-grid-resize-handle-s {
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
@ -1561,6 +1587,7 @@ a[href],
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.kanban-comment-badge-pulse,
|
||||||
.message-composer-orbit-path,
|
.message-composer-orbit-path,
|
||||||
.message-composer-orbit-glow {
|
.message-composer-orbit-glow {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ const PASSED_GAUNTLET_REAL_AGENT_TEAMS_E2E_REASON =
|
||||||
const PASSED_GAUNTLET_WITH_LIMITS_REASON =
|
const PASSED_GAUNTLET_WITH_LIMITS_REASON =
|
||||||
'This exact model route passed the deeper OpenCode Agent Teams gauntlet, but has a production caveat such as free-route capacity, preview availability, cost, or latency variance.';
|
'This exact model route passed the deeper OpenCode Agent Teams gauntlet, but has a production caveat such as free-route capacity, preview availability, cost, or latency variance.';
|
||||||
|
|
||||||
const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set<string>([]);
|
const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set<string>(['opencode/big-pickle']);
|
||||||
|
|
||||||
const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set<string>([]);
|
const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set<string>([
|
||||||
|
'opencode/minimax-m2.5-free',
|
||||||
|
]);
|
||||||
|
|
||||||
const OPENCODE_TEAM_TESTED_MODELS = new Set<string>([
|
const OPENCODE_TEAM_TESTED_MODELS = new Set<string>([
|
||||||
'openrouter/anthropic/claude-haiku-4.5',
|
'openrouter/anthropic/claude-haiku-4.5',
|
||||||
|
|
@ -52,7 +54,7 @@ const OPENCODE_TEAM_TESTED_MODELS = new Set<string>([
|
||||||
'openrouter/z-ai/glm-5.1',
|
'openrouter/z-ai/glm-5.1',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const OPENCODE_TEAM_TESTED_WITH_LIMITS_MODELS = new Set<string>(['opencode/minimax-m2.5-free']);
|
const OPENCODE_TEAM_TESTED_WITH_LIMITS_MODELS = new Set<string>([]);
|
||||||
|
|
||||||
const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map<string, string>([
|
const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map<string, string>([
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -233,8 +233,18 @@ describe('taskChangeReviewability', () => {
|
||||||
it('tolerates malformed cached scope and diagnostic shapes', () => {
|
it('tolerates malformed cached scope and diagnostic shapes', () => {
|
||||||
const result = changeSet({
|
const result = changeSet({
|
||||||
totalFiles: 'not-a-number' as unknown as number,
|
totalFiles: 'not-a-number' as unknown as number,
|
||||||
reviewDiagnostics: {} as unknown as TaskChangeSetV2['reviewDiagnostics'],
|
reviewDiagnostics: [
|
||||||
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
|
null,
|
||||||
|
'bad-diagnostic',
|
||||||
|
{
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'Recovered warning from cache.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
] as unknown as TaskChangeSetV2['reviewDiagnostics'],
|
||||||
|
warnings: [42, EMPTY_INTERVAL_NO_EDITS_WARNING] as unknown as string[],
|
||||||
scope: {
|
scope: {
|
||||||
taskId: 'task-a',
|
taskId: 'task-a',
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
|
|
@ -244,8 +254,16 @@ describe('taskChangeReviewability', () => {
|
||||||
} as unknown as TaskChangeSetV2['scope'],
|
} as unknown as TaskChangeSetV2['scope'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
|
const status = classifyTaskChangeReviewability(result);
|
||||||
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
|
||||||
|
expect(status.reviewability).toBe('attention_required');
|
||||||
|
expect(status.diagnostics).toHaveLength(3);
|
||||||
|
expect(status.diagnostics.map((diagnostic) => diagnostic.message)).toEqual([
|
||||||
|
'bad-diagnostic',
|
||||||
|
'Recovered warning from cache.',
|
||||||
|
'No file edits have been observed in the active task interval yet.',
|
||||||
|
]);
|
||||||
|
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('confirms empty high-confidence summaries as no changes', () => {
|
it('confirms empty high-confidence summaries as no changes', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types';
|
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types/review';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TaskChangeDiagnosticCode,
|
TaskChangeDiagnosticCode,
|
||||||
|
|
@ -197,25 +197,64 @@ function getInputWarnings(input: ReviewabilityInput): string[] {
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskChangeDiagnosticSeverity(value: unknown): value is TaskChangeDiagnosticSeverity {
|
||||||
|
return value === 'info' || value === 'warning' || value === 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskChangeDiagnosticSource(
|
||||||
|
value: unknown
|
||||||
|
): value is NonNullable<TaskChangeReviewDiagnostic['source']> {
|
||||||
|
return value === 'ledger' || value === 'legacy' || value === 'summary' || value === 'runtime';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReviewDiagnosticInput(value: unknown): TaskChangeReviewDiagnostic | null {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const message = value.trim();
|
||||||
|
return message ? createTaskChangeDiagnosticFromWarning(message) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
|
||||||
|
const message = typeof candidate.message === 'string' ? candidate.message.trim() : '';
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = isTaskChangeDiagnosticSource(candidate.source) ? { source: candidate.source } : {};
|
||||||
|
if (
|
||||||
|
typeof candidate.code === 'string' &&
|
||||||
|
TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(candidate.code) &&
|
||||||
|
isTaskChangeDiagnosticSeverity(candidate.severity) &&
|
||||||
|
typeof candidate.reviewBlocking === 'boolean'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
code: candidate.code,
|
||||||
|
severity: candidate.severity,
|
||||||
|
reviewBlocking: candidate.reviewBlocking,
|
||||||
|
message,
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message,
|
||||||
|
...source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getInputReviewDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
function getInputReviewDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
||||||
if (!Array.isArray(input.reviewDiagnostics)) {
|
if (!Array.isArray(input.reviewDiagnostics)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return input.reviewDiagnostics.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => {
|
return input.reviewDiagnostics
|
||||||
if (!diagnostic || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
|
.map(normalizeReviewDiagnosticInput)
|
||||||
return false;
|
.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => diagnostic !== null);
|
||||||
}
|
|
||||||
const candidate = diagnostic as Partial<TaskChangeReviewDiagnostic>;
|
|
||||||
return (
|
|
||||||
typeof candidate.code === 'string' &&
|
|
||||||
TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(candidate.code) &&
|
|
||||||
(candidate.severity === 'info' ||
|
|
||||||
candidate.severity === 'warning' ||
|
|
||||||
candidate.severity === 'error') &&
|
|
||||||
typeof candidate.reviewBlocking === 'boolean' &&
|
|
||||||
typeof candidate.message === 'string'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInputToolUseIds(input: ReviewabilityInput): string[] {
|
function getInputToolUseIds(input: ReviewabilityInput): string[] {
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,47 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
|
||||||
).toEqual({ active: true, reason: 'review_pickup_required' });
|
).toEqual({ active: true, reason: 'review_pickup_required' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows strict review pickup while shadow data is collecting even when short-window nudge rate is high', () => {
|
||||||
|
expect(
|
||||||
|
decideMemberWorkSyncNudgeActivation({
|
||||||
|
status: status({
|
||||||
|
agenda: {
|
||||||
|
...status().agenda,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
taskId: 'task-review',
|
||||||
|
displayId: '#2',
|
||||||
|
subject: 'Review current request',
|
||||||
|
kind: 'review',
|
||||||
|
assignee: 'alice',
|
||||||
|
priority: 'review_requested',
|
||||||
|
reason: 'current_cycle_review_assigned',
|
||||||
|
evidence: {
|
||||||
|
status: 'completed',
|
||||||
|
owner: 'bob',
|
||||||
|
reviewer: 'alice',
|
||||||
|
reviewState: 'review',
|
||||||
|
reviewCycleId: 'evt-review-request',
|
||||||
|
reviewRequestEventId: 'evt-review-request',
|
||||||
|
reviewObligation: 'review_pickup_required',
|
||||||
|
canBypassPhase2: true,
|
||||||
|
historyEventIds: ['evt-review-request'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
metrics: metrics({
|
||||||
|
phase2Readiness: {
|
||||||
|
...metrics().phase2Readiness,
|
||||||
|
state: 'collecting_shadow_data',
|
||||||
|
reasons: ['insufficient_status_events', 'would_nudge_rate_high'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
).toEqual({ active: true, reason: 'review_pickup_required' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not activate when blocking safety metrics are present', () => {
|
it('does not activate when blocking safety metrics are present', () => {
|
||||||
expect(
|
expect(
|
||||||
decideMemberWorkSyncNudgeActivation({
|
decideMemberWorkSyncNudgeActivation({
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,56 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps unknown cached diagnostics as blocking legacy warnings', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
const repo = new JsonTaskChangeSummaryCacheRepository();
|
||||||
|
|
||||||
|
await repo.save(
|
||||||
|
buildEntry({
|
||||||
|
summary: {
|
||||||
|
...buildEntry().summary,
|
||||||
|
files: [],
|
||||||
|
totalFiles: 0,
|
||||||
|
totalLinesAdded: 0,
|
||||||
|
totalLinesRemoved: 0,
|
||||||
|
reviewDiagnostics: [
|
||||||
|
'string diagnostic from older cache',
|
||||||
|
{
|
||||||
|
code: 'future_warning_code',
|
||||||
|
severity: 'info',
|
||||||
|
reviewBlocking: false,
|
||||||
|
message: 'Future diagnostic from cache.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
] as unknown as PersistedTaskChangeSummaryEntry['summary']['reviewDiagnostics'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = await repo.load('team-a', '1');
|
||||||
|
|
||||||
|
expect(loaded?.summary.reviewDiagnostics).toEqual([
|
||||||
|
{
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'string diagnostic from older cache',
|
||||||
|
source: 'legacy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'legacy_warning',
|
||||||
|
severity: 'warning',
|
||||||
|
reviewBlocking: true,
|
||||||
|
message: 'Future diagnostic from cache.',
|
||||||
|
source: 'summary',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(loaded?.summary ? resolveTaskChangePresenceFromResult(loaded.summary) : null).toBe(
|
||||||
|
'needs_attention'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('treats expired entries as cache misses', async () => {
|
it('treats expired entries as cache misses', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ const liveDescribe =
|
||||||
? describe
|
? describe
|
||||||
: describe.skip;
|
: describe.skip;
|
||||||
|
|
||||||
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
|
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() ?? process.cwd();
|
||||||
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle';
|
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() ?? 'opencode/big-pickle';
|
||||||
|
|
||||||
liveDescribe('OpenCode review pickup live e2e', () => {
|
liveDescribe('OpenCode review pickup live e2e', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
|
|
@ -154,7 +154,7 @@ liveDescribe('OpenCode review pickup live e2e', () => {
|
||||||
const displayId = '7142f765';
|
const displayId = '7142f765';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const progressEvents: Array<{ message?: string }> = [];
|
const progressEvents: { message?: string }[] = [];
|
||||||
await harness.svc.createTeam(
|
await harness.svc.createTeam(
|
||||||
{
|
{
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -643,8 +643,8 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
expect(host.textContent).toContain('Not recommended');
|
expect(host.textContent).toContain('Not recommended');
|
||||||
expect(host.textContent).toContain('Unavailable in OpenCode');
|
expect(host.textContent).toContain('Unavailable in OpenCode');
|
||||||
expect(host.textContent).toContain('Tested');
|
expect(host.textContent).toContain('Tested');
|
||||||
expect(host.textContent).toContain('Tested with limits');
|
expect(host.textContent).toContain('Recommended with limits');
|
||||||
expect(host.textContent).not.toContain('Recommended only');
|
expect(host.textContent).toContain('Recommended only');
|
||||||
expect(host.textContent).not.toContain('Set OpenCode default');
|
expect(host.textContent).not.toContain('Set OpenCode default');
|
||||||
expect(
|
expect(
|
||||||
Array.from(host.querySelectorAll('button')).some(
|
Array.from(host.querySelectorAll('button')).some(
|
||||||
|
|
@ -656,15 +656,13 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
const connectedBadge = Array.from(host.querySelectorAll('span')).find(
|
const connectedBadge = Array.from(host.querySelectorAll('span')).find(
|
||||||
(span) => span.textContent === 'Connected'
|
(span) => span.textContent === 'Connected'
|
||||||
) as HTMLElement | undefined;
|
);
|
||||||
expect(connectedBadge?.style.color).toBeTruthy();
|
expect(connectedBadge?.style.color).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
(host.querySelector('[data-testid="runtime-provider-model-search"]') as HTMLElement | null)
|
host.querySelector('[data-testid="runtime-provider-model-search"]')?.style.paddingLeft
|
||||||
?.style.paddingLeft
|
|
||||||
).toBe('42px');
|
).toBe('42px');
|
||||||
expect(
|
expect(
|
||||||
(host.querySelector('[data-testid="runtime-provider-model-list"]') as HTMLElement | null)
|
host.querySelector('[data-testid="runtime-provider-model-list"]')?.style.maxHeight
|
||||||
?.style.maxHeight
|
|
||||||
).toBe('300px');
|
).toBe('300px');
|
||||||
expect(host.textContent).not.toContain('OpenRouterfree');
|
expect(host.textContent).not.toContain('OpenRouterfree');
|
||||||
const firstTestButton = Array.from(host.querySelectorAll('button')).find(
|
const firstTestButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
|
@ -673,19 +671,16 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
expect(firstTestButton?.className).toContain('border');
|
expect(firstTestButton?.className).toContain('border');
|
||||||
const modelResult = host.querySelector(
|
const modelResult = host.querySelector(
|
||||||
'[data-testid="runtime-provider-model-result-openrouter/openai/gpt-oss-20b:free"]'
|
'[data-testid="runtime-provider-model-result-openrouter/openai/gpt-oss-20b:free"]'
|
||||||
) as HTMLElement | null;
|
);
|
||||||
expect(modelResult?.style.color).toBe('#86efac');
|
expect(modelResult?.style.color).toBe('#86efac');
|
||||||
expect((host.textContent ?? '').indexOf('mistralai/codestral-2508')).toBeLessThan(
|
expect((host.textContent ?? '').indexOf('mistralai/codestral-2508')).toBeLessThan(
|
||||||
(host.textContent ?? '').indexOf('anthropic/claude-sonnet-4.6')
|
(host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')
|
||||||
);
|
);
|
||||||
expect((host.textContent ?? '').indexOf('anthropic/claude-sonnet-4.6')).toBeLessThan(
|
expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan(
|
||||||
(host.textContent ?? '').indexOf('minimax-m2.5-free')
|
(host.textContent ?? '').indexOf('minimax-m2.5-free')
|
||||||
);
|
);
|
||||||
expect((host.textContent ?? '').indexOf('minimax-m2.5-free')).toBeLessThan(
|
expect((host.textContent ?? '').indexOf('minimax-m2.5-free')).toBeLessThan(
|
||||||
(host.textContent ?? '').indexOf('opencode/big-pickle')
|
(host.textContent ?? '').indexOf('mistralai/codestral-2508')
|
||||||
);
|
|
||||||
expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan(
|
|
||||||
(host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')
|
|
||||||
);
|
);
|
||||||
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')).toBeLessThan(
|
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')).toBeLessThan(
|
||||||
(host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free')
|
(host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free')
|
||||||
|
|
@ -767,7 +762,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
|
|
||||||
const searchInput = host.querySelector(
|
const searchInput = host.querySelector(
|
||||||
'[data-testid="runtime-provider-model-search"]'
|
'[data-testid="runtime-provider-model-search"]'
|
||||||
) as HTMLInputElement | null;
|
);
|
||||||
|
|
||||||
expect(searchInput).not.toBeNull();
|
expect(searchInput).not.toBeNull();
|
||||||
expect(searchInput?.disabled).toBe(false);
|
expect(searchInput?.disabled).toBe(false);
|
||||||
|
|
@ -902,7 +897,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
const logo = host.querySelector(
|
const logo = host.querySelector(
|
||||||
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
|
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
|
||||||
) as HTMLElement | null;
|
);
|
||||||
expect(logo).not.toBeNull();
|
expect(logo).not.toBeNull();
|
||||||
expect(logo?.className).toContain('runtime-provider-brand-icon');
|
expect(logo?.className).toContain('runtime-provider-brand-icon');
|
||||||
expect(logo?.querySelector('svg,img')).not.toBeNull();
|
expect(logo?.querySelector('svg,img')).not.toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ import {
|
||||||
} from '@renderer/utils/openCodeModelRecommendations';
|
} from '@renderer/utils/openCodeModelRecommendations';
|
||||||
|
|
||||||
describe('getOpenCodeTeamModelRecommendation', () => {
|
describe('getOpenCodeTeamModelRecommendation', () => {
|
||||||
|
it('marks deeply gauntlet-qualified OpenCode-hosted routes as recommended', () => {
|
||||||
|
expect(getOpenCodeTeamModelRecommendation('opencode/big-pickle')).toMatchObject({
|
||||||
|
level: 'recommended',
|
||||||
|
label: 'Recommended',
|
||||||
|
});
|
||||||
|
expect(isOpenCodeTeamModelRecommended('opencode/big-pickle')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps Claude Sonnet 4.6 as tested while recommendations are disabled', () => {
|
it('keeps Claude Sonnet 4.6 as tested while recommendations are disabled', () => {
|
||||||
expect(
|
expect(
|
||||||
getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-sonnet-4.6')
|
getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-sonnet-4.6')
|
||||||
|
|
@ -93,9 +101,10 @@ describe('getOpenCodeTeamModelRecommendation', () => {
|
||||||
|
|
||||||
it('keeps similarly named models distinct when real E2E disagreed', () => {
|
it('keeps similarly named models distinct when real E2E disagreed', () => {
|
||||||
expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({
|
expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({
|
||||||
level: 'tested-with-limits',
|
level: 'recommended-with-limits',
|
||||||
label: 'Tested with limits',
|
label: 'Recommended with limits',
|
||||||
});
|
});
|
||||||
|
expect(isOpenCodeTeamModelRecommended('opencode/minimax-m2.5-free')).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free')
|
getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free')
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
|
|
@ -787,7 +796,6 @@ describe('getOpenCodeTeamModelRecommendation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not label noisy or unproven models as good or bad', () => {
|
it('does not label noisy or unproven models as good or bad', () => {
|
||||||
expect(getOpenCodeTeamModelRecommendation('opencode/big-pickle')).toBeNull();
|
|
||||||
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20-unknown')).toBeNull();
|
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20-unknown')).toBeNull();
|
||||||
expect(getOpenCodeTeamModelRecommendation('')).toBeNull();
|
expect(getOpenCodeTeamModelRecommendation('')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -805,10 +813,10 @@ describe('getOpenCodeTeamModelRecommendation', () => {
|
||||||
expect(
|
expect(
|
||||||
[...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right))
|
[...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right))
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
'opencode/big-pickle',
|
||||||
|
'opencode/minimax-m2.5-free',
|
||||||
'openrouter/mistralai/codestral-2508',
|
'openrouter/mistralai/codestral-2508',
|
||||||
'openrouter/anthropic/claude-sonnet-4.6',
|
'openrouter/anthropic/claude-sonnet-4.6',
|
||||||
'opencode/minimax-m2.5-free',
|
|
||||||
'opencode/big-pickle',
|
|
||||||
'openrouter/qwen/qwen3-coder-plus',
|
'openrouter/qwen/qwen3-coder-plus',
|
||||||
'openrouter/openai/gpt-oss-20b:free',
|
'openrouter/openai/gpt-oss-20b:free',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue