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", "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"
} }

View file

@ -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' };
} }

View file

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

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 { 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">

View file

@ -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 = '';

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 { 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"

View file

@ -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;

View file

@ -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>([
[ [

View file

@ -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', () => {

View file

@ -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[] {

View file

@ -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({

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

View file

@ -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,

View file

@ -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();

View file

@ -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',
]); ]);