diff --git a/runtime.lock.json b/runtime.lock.json index 5170eab6..e9379d6d 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -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" } diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts index a24ce2b0..0115c116 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.ts @@ -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' }; } diff --git a/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts index 8931f12b..19fcd881 100644 --- a/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts +++ b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts @@ -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 { + 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; - 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, }; } diff --git a/src/renderer/components/team/UnreadCommentsBadge.test.tsx b/src/renderer/components/team/UnreadCommentsBadge.test.tsx new file mode 100644 index 00000000..9559a621 --- /dev/null +++ b/src/renderer/components/team/UnreadCommentsBadge.test.tsx @@ -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 { + 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(); + }); + }); +}); diff --git a/src/renderer/components/team/UnreadCommentsBadge.tsx b/src/renderer/components/team/UnreadCommentsBadge.tsx index f1dd5011..363f54eb 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.tsx @@ -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 ( - - - {totalCount} - - {unreadCount > 0 ? ( - - {unreadCount} + + + + {totalCount} - ) : null} + {unreadCount > 0 ? ( + + {unreadCount} + + ) : null} + diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx index 7d8de7ec..72a3fc7c 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.test.tsx @@ -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.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 { + await Promise.resolve(); + await Promise.resolve(); +} + +async function rerenderTaskCard( + root: ReturnType, + props: Partial> = {} +): Promise { + await act(async () => { + root.render(createTaskCardElement(props)); + await flushReact(); + }); +} + +function createStrictTaskCardElement( + props: Partial> = {} +): React.ReactElement { + return React.createElement(React.StrictMode, null, createTaskCardElement(props)); +} + +async function renderStrictTaskCard( + props: Partial> = {} +): Promise<{ host: HTMLDivElement; root: ReturnType }> { + 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, + props: Partial> = {} +): Promise { + await act(async () => { + root.render(createStrictTaskCardElement(props)); + await flushReact(); + }); +} + +afterEach(() => { + unreadBadgeMock.props.length = 0; + unreadCommentCountMock.value = 0; +}); + async function renderTaskCard( props: Partial> = {} ): Promise<{ host: HTMLDivElement; root: ReturnType }> { @@ -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 = ''; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index f35445cb..b46cdc79 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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; + 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} - + {onDeleteTask ? ( ([]); +const OPENCODE_TEAM_RECOMMENDED_MODELS = new Set(['opencode/big-pickle']); -const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set([]); +const OPENCODE_TEAM_RECOMMENDED_WITH_LIMITS_MODELS = new Set([ + 'opencode/minimax-m2.5-free', +]); const OPENCODE_TEAM_TESTED_MODELS = new Set([ 'openrouter/anthropic/claude-haiku-4.5', @@ -52,7 +54,7 @@ const OPENCODE_TEAM_TESTED_MODELS = new Set([ 'openrouter/z-ai/glm-5.1', ]); -const OPENCODE_TEAM_TESTED_WITH_LIMITS_MODELS = new Set(['opencode/minimax-m2.5-free']); +const OPENCODE_TEAM_TESTED_WITH_LIMITS_MODELS = new Set([]); const OPENCODE_TEAM_UNAVAILABLE_MODELS = new Map([ [ diff --git a/src/shared/utils/__tests__/taskChangeReviewability.test.ts b/src/shared/utils/__tests__/taskChangeReviewability.test.ts index e8680311..2d0ee21e 100644 --- a/src/shared/utils/__tests__/taskChangeReviewability.test.ts +++ b/src/shared/utils/__tests__/taskChangeReviewability.test.ts @@ -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', () => { diff --git a/src/shared/utils/taskChangeReviewability.ts b/src/shared/utils/taskChangeReviewability.ts index 13749d21..e3d8f07b 100644 --- a/src/shared/utils/taskChangeReviewability.ts +++ b/src/shared/utils/taskChangeReviewability.ts @@ -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 { + 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; + 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; - 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[] { diff --git a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts index 58994dc6..876fed3f 100644 --- a/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts +++ b/test/features/member-work-sync/core/application/MemberWorkSyncNudgeActivationPolicy.test.ts @@ -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({ diff --git a/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts index 9649711b..522f9057 100644 --- a/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts +++ b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts @@ -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); diff --git a/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts b/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts index 09ba2b66..40b3cf23 100644 --- a/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts +++ b/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts @@ -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, diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index 10b231dc..60ee82a5 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -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(); diff --git a/test/renderer/utils/openCodeModelRecommendations.test.ts b/test/renderer/utils/openCodeModelRecommendations.test.ts index 95440e57..3cffc646 100644 --- a/test/renderer/utils/openCodeModelRecommendations.test.ts +++ b/test/renderer/utils/openCodeModelRecommendations.test.ts @@ -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', ]);