feat(team): refine review workflow indicators

This commit is contained in:
777genius 2026-05-09 22:10:29 +03:00
parent bceef9dec5
commit 92e84c8461
16 changed files with 805 additions and 106 deletions

View file

@ -1,27 +1,27 @@
{
"version": "0.0.24",
"sourceRef": "v0.0.24",
"version": "0.0.25",
"sourceRef": "v0.0.25",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v1.2.0",
"assets": {
"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",
"binaryName": "claude-multimodel"
},
"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",
"binaryName": "claude-multimodel"
},
"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",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.24.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.25.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -65,6 +65,13 @@ export function decideMemberWorkSyncNudgeActivation(input: {
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)) {
return { active: false, reason: 'blocking_metrics' };
}

View file

@ -18,6 +18,18 @@ function isTaskChangeDiagnosticCode(value: unknown): value is TaskChangeReviewDi
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 {
if (typeof value !== 'string' || value.trim() === '') return null;
const date = new Date(value);
@ -47,30 +59,47 @@ function normalizeFileSummary(value: unknown): FileChangeSummary | 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;
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
if (
!isTaskChangeDiagnosticCode(candidate.code) ||
(candidate.severity !== 'info' &&
candidate.severity !== 'warning' &&
candidate.severity !== 'error') ||
typeof candidate.reviewBlocking !== 'boolean' ||
typeof candidate.message !== 'string'
) {
const message = typeof candidate.message === 'string' ? candidate.message.trim() : '';
if (!message) {
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 {
code: candidate.code,
severity: candidate.severity,
reviewBlocking: candidate.reviewBlocking,
message: candidate.message,
...(candidate.source === 'ledger' ||
candidate.source === 'legacy' ||
candidate.source === 'summary' ||
candidate.source === 'runtime'
? { source: candidate.source }
: {}),
code: 'legacy_warning',
severity: 'warning',
reviewBlocking: true,
message,
...source,
};
}

View 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();
});
});
});

View file

@ -4,27 +4,38 @@ import { MessageSquare } from 'lucide-react';
interface UnreadCommentsBadgeProps {
unreadCount: number;
totalCount: number;
pulseKey?: number;
}
export const UnreadCommentsBadge = ({
unreadCount,
totalCount,
pulseKey,
}: UnreadCommentsBadgeProps): React.JSX.Element | null => {
if (totalCount === 0) return null;
const shouldPulse = (pulseKey ?? 0) > 0;
return (
<Tooltip>
<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)]">
<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>
{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
key={shouldPulse ? pulseKey : 'idle'}
className={`relative inline-flex size-6 items-center justify-center ${
shouldPulse ? 'kanban-comment-badge-pulse' : ''
}`}
>
<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>
) : 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>
</TooltipTrigger>
<TooltipContent side="top">

View file

@ -1,14 +1,30 @@
/* 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';
const unreadBadgeMock = vi.hoisted(() => ({
props: [] as { unreadCount: number; totalCount: number; pulseKey?: number }[],
}));
const unreadCommentCountMock = vi.hoisted(() => ({
value: 0,
}));
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
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', () => ({
@ -55,12 +71,14 @@ vi.mock('@renderer/hooks/useTheme', () => ({
}));
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 type { TeamTaskWithKanban } from '@shared/types/team';
import type { TaskComment, TeamTaskWithKanban } from '@shared/types/team';
const baseTask: TeamTaskWithKanban = {
id: 'task-1',
@ -81,6 +99,97 @@ const baseTask: TeamTaskWithKanban = {
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(
props: Partial<React.ComponentProps<typeof KanbanTaskCard>> = {}
): Promise<{ host: HTMLDivElement; root: ReturnType<typeof createRoot> }> {
@ -90,32 +199,191 @@ async function renderTaskCard(
const root = createRoot(host);
await act(async () => {
root.render(
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,
})
);
await Promise.resolve();
root.render(createTaskCardElement(props));
await flushReact();
});
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', () => {
afterEach(() => {
document.body.innerHTML = '';

View file

@ -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 { MemberBadge } from '@renderer/components/team/MemberBadge';
@ -32,7 +32,13 @@ import {
XCircle,
} from 'lucide-react';
import type { KanbanColumnId, KanbanTaskState, TeamTask, TeamTaskWithKanban } from '@shared/types';
import type {
KanbanColumnId,
KanbanTaskState,
TaskComment,
TeamTask,
TeamTaskWithKanban,
} from '@shared/types';
interface KanbanTaskCardProps {
task: TeamTaskWithKanban;
@ -63,6 +69,65 @@ interface DependencyBadgeProps {
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 = ({
taskId,
taskMap,
@ -248,6 +313,16 @@ export const KanbanTaskCard = memo(
}: KanbanTaskCardProps): React.JSX.Element {
const { isLight } = useTheme();
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 blocksIds = task.blocks?.filter((id) => id.length > 0) ?? [];
const hasBlockedBy = blockedByIds.length > 0;
@ -267,6 +342,11 @@ export const KanbanTaskCard = memo(
canDisplay &&
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention');
const changesNeedAttention = task.changePresence === 'needs_attention';
useEffect(() => {
syncCommentPulse({ taskKey: commentPulseTaskKey, comments });
}, [commentCount, commentPulseTaskKey, comments]);
const metaActions = (
<>
{canOpenChanges ? (
@ -285,7 +365,11 @@ export const KanbanTaskCard = memo(
}}
/>
) : null}
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
<UnreadCommentsBadge
unreadCount={unreadCount}
totalCount={commentCount}
pulseKey={visibleCommentPulseKey}
/>
{onDeleteTask ? (
<TaskActionIconButton
label="Delete task"

View file

@ -363,6 +363,32 @@
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-s {
left: 50%;
@ -1561,6 +1587,7 @@ a[href],
}
@media (prefers-reduced-motion: reduce) {
.kanban-comment-badge-pulse,
.message-composer-orbit-path,
.message-composer-orbit-glow {
animation: none;

View file

@ -24,9 +24,11 @@ const PASSED_GAUNTLET_REAL_AGENT_TEAMS_E2E_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.';
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>([
'openrouter/anthropic/claude-haiku-4.5',
@ -52,7 +54,7 @@ const OPENCODE_TEAM_TESTED_MODELS = new Set<string>([
'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>([
[

View file

@ -233,8 +233,18 @@ describe('taskChangeReviewability', () => {
it('tolerates malformed cached scope and diagnostic shapes', () => {
const result = changeSet({
totalFiles: 'not-a-number' as unknown as number,
reviewDiagnostics: {} as unknown as TaskChangeSetV2['reviewDiagnostics'],
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
reviewDiagnostics: [
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: {
taskId: 'task-a',
memberName: 'alice',
@ -244,8 +254,16 @@ describe('taskChangeReviewability', () => {
} as unknown as TaskChangeSetV2['scope'],
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
const status = classifyTaskChangeReviewability(result);
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', () => {

View file

@ -1,4 +1,4 @@
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types';
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types/review';
import type {
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[] {
if (!Array.isArray(input.reviewDiagnostics)) {
return [];
}
return input.reviewDiagnostics.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => {
if (!diagnostic || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
return false;
}
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'
);
});
return input.reviewDiagnostics
.map(normalizeReviewDiagnosticInput)
.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => diagnostic !== null);
}
function getInputToolUseIds(input: ReviewabilityInput): string[] {

View file

@ -263,6 +263,47 @@ describe('MemberWorkSyncNudgeActivationPolicy', () => {
).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', () => {
expect(
decideMemberWorkSyncNudgeActivation({

View file

@ -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 () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -47,8 +47,8 @@ const liveDescribe =
? describe
: describe.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle';
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() ?? process.cwd();
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() ?? 'opencode/big-pickle';
liveDescribe('OpenCode review pickup live e2e', () => {
let tempDir: string;
@ -154,7 +154,7 @@ liveDescribe('OpenCode review pickup live e2e', () => {
const displayId = '7142f765';
try {
const progressEvents: Array<{ message?: string }> = [];
const progressEvents: { message?: string }[] = [];
await harness.svc.createTeam(
{
teamName,

View file

@ -643,8 +643,8 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).toContain('Not recommended');
expect(host.textContent).toContain('Unavailable in OpenCode');
expect(host.textContent).toContain('Tested');
expect(host.textContent).toContain('Tested with limits');
expect(host.textContent).not.toContain('Recommended only');
expect(host.textContent).toContain('Recommended with limits');
expect(host.textContent).toContain('Recommended only');
expect(host.textContent).not.toContain('Set OpenCode default');
expect(
Array.from(host.querySelectorAll('button')).some(
@ -656,15 +656,13 @@ describe('RuntimeProviderManagementPanelView', () => {
).not.toBeNull();
const connectedBadge = Array.from(host.querySelectorAll('span')).find(
(span) => span.textContent === 'Connected'
) as HTMLElement | undefined;
);
expect(connectedBadge?.style.color).toBeTruthy();
expect(
(host.querySelector('[data-testid="runtime-provider-model-search"]') as HTMLElement | null)
?.style.paddingLeft
host.querySelector('[data-testid="runtime-provider-model-search"]')?.style.paddingLeft
).toBe('42px');
expect(
(host.querySelector('[data-testid="runtime-provider-model-list"]') as HTMLElement | null)
?.style.maxHeight
host.querySelector('[data-testid="runtime-provider-model-list"]')?.style.maxHeight
).toBe('300px');
expect(host.textContent).not.toContain('OpenRouterfree');
const firstTestButton = Array.from(host.querySelectorAll('button')).find(
@ -673,19 +671,16 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(firstTestButton?.className).toContain('border');
const modelResult = host.querySelector(
'[data-testid="runtime-provider-model-result-openrouter/openai/gpt-oss-20b:free"]'
) as HTMLElement | null;
);
expect(modelResult?.style.color).toBe('#86efac');
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')
);
expect((host.textContent ?? '').indexOf('minimax-m2.5-free')).toBeLessThan(
(host.textContent ?? '').indexOf('opencode/big-pickle')
);
expect((host.textContent ?? '').indexOf('opencode/big-pickle')).toBeLessThan(
(host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')
(host.textContent ?? '').indexOf('mistralai/codestral-2508')
);
expect((host.textContent ?? '').indexOf('qwen/qwen3-coder-plus')).toBeLessThan(
(host.textContent ?? '').indexOf('openrouter/openai/gpt-oss-20b:free')
@ -767,7 +762,7 @@ describe('RuntimeProviderManagementPanelView', () => {
const searchInput = host.querySelector(
'[data-testid="runtime-provider-model-search"]'
) as HTMLInputElement | null;
);
expect(searchInput).not.toBeNull();
expect(searchInput?.disabled).toBe(false);
@ -902,7 +897,7 @@ describe('RuntimeProviderManagementPanelView', () => {
for (const provider of providers) {
const logo = host.querySelector(
`[data-testid="runtime-provider-logo-${provider.providerId}"]`
) as HTMLElement | null;
);
expect(logo).not.toBeNull();
expect(logo?.className).toContain('runtime-provider-brand-icon');
expect(logo?.querySelector('svg,img')).not.toBeNull();

View file

@ -7,6 +7,14 @@ import {
} from '@renderer/utils/openCodeModelRecommendations';
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', () => {
expect(
getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-sonnet-4.6')
@ -93,9 +101,10 @@ describe('getOpenCodeTeamModelRecommendation', () => {
it('keeps similarly named models distinct when real E2E disagreed', () => {
expect(getOpenCodeTeamModelRecommendation('opencode/minimax-m2.5-free')).toMatchObject({
level: 'tested-with-limits',
label: 'Tested with limits',
level: 'recommended-with-limits',
label: 'Recommended with limits',
});
expect(isOpenCodeTeamModelRecommended('opencode/minimax-m2.5-free')).toBe(true);
expect(
getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2.5:free')
).toMatchObject({
@ -787,7 +796,6 @@ describe('getOpenCodeTeamModelRecommendation', () => {
});
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('')).toBeNull();
});
@ -805,10 +813,10 @@ describe('getOpenCodeTeamModelRecommendation', () => {
expect(
[...models].sort((left, right) => compareOpenCodeTeamModelRecommendations(left, right))
).toEqual([
'opencode/big-pickle',
'opencode/minimax-m2.5-free',
'openrouter/mistralai/codestral-2508',
'openrouter/anthropic/claude-sonnet-4.6',
'opencode/minimax-m2.5-free',
'opencode/big-pickle',
'openrouter/qwen/qwen3-coder-plus',
'openrouter/openai/gpt-oss-20b:free',
]);