From e333d09d9c0716b4a6f602f7dcaa0f138da014be Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 17 May 2026 14:18:54 +0300 Subject: [PATCH] fix: harden task change review flows --- .../hooks/useGraphChangeReviewDialog.tsx | 106 +++++ .../hooks/useGraphMemberDetailDialog.tsx | 138 ++++++ .../hooks/useGraphSendMessageDialog.tsx | 122 ++++++ .../hooks/useGraphSurfaceInteractions.tsx | 88 ++++ .../renderer/hooks/useGraphTaskActions.tsx | 246 +++++++++++ .../hooks/useGraphTaskDetailDialog.tsx | 78 ++++ .../renderer/ui/GraphMemberLogPreviewHud.tsx | 13 +- .../renderer/ui/GraphNodePopover.tsx | 15 +- .../agent-graph/renderer/ui/GraphTaskCard.tsx | 3 + .../renderer/ui/TeamGraphOverlay.tsx | 100 ++--- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 127 ++---- src/main/services/team/TaskBoundaryParser.ts | 32 +- src/main/services/team/TaskChangeComputer.ts | 297 +++++++++---- .../team/review/ChangeReviewDialog.tsx | 37 +- .../team/review/FileSectionDiff.tsx | 88 ++-- .../team/review/FileSectionHeader.tsx | 49 +-- .../components/team/review/ReviewToolbar.tsx | 31 +- .../team/review/reviewContentPreview.ts | 99 +++++ .../services/team/TaskBoundaryParser.test.ts | 67 +++ .../services/team/TaskChangeComputer.test.ts | 407 +++++++++++++++++- .../team/review/reviewContentPreview.test.ts | 108 +++++ .../GraphMemberLogPreviewHud.test.tsx | 54 +++ .../agent-graph/GraphTaskCard.test.tsx | 167 +++++++ 23 files changed, 2150 insertions(+), 322 deletions(-) create mode 100644 src/features/agent-graph/renderer/hooks/useGraphChangeReviewDialog.tsx create mode 100644 src/features/agent-graph/renderer/hooks/useGraphMemberDetailDialog.tsx create mode 100644 src/features/agent-graph/renderer/hooks/useGraphSendMessageDialog.tsx create mode 100644 src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx create mode 100644 src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx create mode 100644 src/features/agent-graph/renderer/hooks/useGraphTaskDetailDialog.tsx create mode 100644 src/renderer/components/team/review/reviewContentPreview.ts create mode 100644 test/renderer/components/team/review/reviewContentPreview.test.ts create mode 100644 test/renderer/features/agent-graph/GraphTaskCard.test.tsx diff --git a/src/features/agent-graph/renderer/hooks/useGraphChangeReviewDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphChangeReviewDialog.tsx new file mode 100644 index 00000000..fd3ab9c1 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphChangeReviewDialog.tsx @@ -0,0 +1,106 @@ +import { lazy, Suspense, useCallback, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; +import { + buildTaskChangeRequestOptions, + type TaskChangeRequestOptions, +} from '@renderer/utils/taskChangeRequest'; +import { useShallow } from 'zustand/react/shallow'; + +const ChangeReviewDialog = lazy(() => + import('@renderer/components/team/review/ChangeReviewDialog').then((m) => ({ + default: m.ChangeReviewDialog, + })) +); + +interface GraphChangeReviewDialogState { + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; +} + +interface UseGraphChangeReviewDialogResult { + dialog: React.ReactNode; + openMemberChanges: (memberName: string, filePath?: string) => void; + openTaskChanges: (taskId: string, filePath?: string) => void; +} + +export function useGraphChangeReviewDialog(teamName: string): UseGraphChangeReviewDialogResult { + const [dialogState, setDialogState] = useState({ + open: false, + mode: 'task', + }); + const { teamData, selectReviewFile } = useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + selectReviewFile: state.selectReviewFile, + })) + ); + + const openTaskChanges = useCallback( + (taskId: string, filePath?: string): void => { + const task = teamData?.tasks.find((candidate) => candidate.id === taskId); + setDialogState({ + open: true, + mode: 'task', + taskId, + memberName: undefined, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); + } + }, + [selectReviewFile, teamData?.tasks] + ); + + const openMemberChanges = useCallback( + (memberName: string, filePath?: string): void => { + setDialogState({ + open: true, + mode: 'agent', + memberName, + taskId: undefined, + initialFilePath: filePath, + taskChangeRequestOptions: undefined, + }); + if (filePath) { + selectReviewFile(filePath); + } + }, + [selectReviewFile] + ); + + const handleOpenChange = useCallback((open: boolean): void => { + setDialogState((previous) => ({ + ...previous, + open, + ...(open ? {} : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })); + }, []); + + return { + openMemberChanges, + openTaskChanges, + dialog: dialogState.open ? ( + + + + ) : null, + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberDetailDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphMemberDetailDialog.tsx new file mode 100644 index 00000000..dac4d2cc --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberDetailDialog.tsx @@ -0,0 +1,138 @@ +import { lazy, Suspense, useCallback, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { + isTeamProvisioningActive, + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; +import type { TeamTaskWithKanban } from '@shared/types'; + +const MemberDetailDialog = lazy(() => + import('@renderer/components/team/members/MemberDetailDialog').then((m) => ({ + default: m.MemberDetailDialog, + })) +); + +interface OpenMemberProfileOptions { + initialActivityFilter?: MemberActivityFilter; + initialTab?: MemberDetailTab; +} + +interface UseGraphMemberDetailDialogInput { + onAssignTask: (owner: string) => void; + onSendMessage: (memberName: string) => void; + onTaskClick: (taskId: string) => void; + onViewMemberChanges: (memberName: string, filePath?: string) => void; +} + +interface UseGraphMemberDetailDialogResult { + dialog: React.ReactNode; + openMemberProfile: (memberName: string, options?: OpenMemberProfileOptions) => void; +} + +export function useGraphMemberDetailDialog( + teamName: string, + { onAssignTask, onSendMessage, onTaskClick, onViewMemberChanges }: UseGraphMemberDetailDialogInput +): UseGraphMemberDetailDialogResult { + const [selectedMemberName, setSelectedMemberName] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState( + null + ); + const { + isTeamProvisioning, + launchParams, + leadActivity, + members, + runtimeRunId, + selectedRuntimeEntry, + selectedSpawnEntry, + teamData, + } = useStore( + useShallow((state) => ({ + isTeamProvisioning: isTeamProvisioningActive(state, teamName), + launchParams: state.launchParamsByTeam[teamName], + leadActivity: state.leadActivityByTeam[teamName], + members: selectResolvedMembersForTeamName(state, teamName), + runtimeRunId: + state.teamAgentRuntimeByTeam[teamName]?.runId ?? + state.memberSpawnSnapshotsByTeam[teamName]?.runId ?? + null, + selectedRuntimeEntry: selectedMemberName + ? state.teamAgentRuntimeByTeam[teamName]?.members[selectedMemberName] + : undefined, + selectedSpawnEntry: selectedMemberName + ? state.memberSpawnStatusesByTeam[teamName]?.[selectedMemberName] + : undefined, + teamData: selectTeamDataForName(state, teamName), + })) + ); + + const selectedMember = + selectedMemberName && members.length > 0 + ? (members.find((member) => member.name === selectedMemberName) ?? null) + : null; + + const openMemberProfile = useCallback( + (memberName: string, options?: OpenMemberProfileOptions): void => { + setSelectedMemberName(memberName); + setSelectedMemberView(options ?? null); + }, + [] + ); + + const closeMemberProfile = useCallback((): void => { + setSelectedMemberName(null); + setSelectedMemberView(null); + }, []); + + return { + openMemberProfile, + dialog: + selectedMemberName && teamData ? ( + + { + if (!selectedMemberName) return; + closeMemberProfile(); + onSendMessage(selectedMemberName); + }} + onAssignTask={() => { + if (!selectedMemberName) return; + closeMemberProfile(); + onAssignTask(selectedMemberName); + }} + onTaskClick={(task: TeamTaskWithKanban) => { + closeMemberProfile(); + onTaskClick(task.id); + }} + onViewMemberChanges={(memberName, filePath) => { + closeMemberProfile(); + onViewMemberChanges(memberName, filePath); + }} + /> + + ) : null, + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphSendMessageDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphSendMessageDialog.tsx new file mode 100644 index 00000000..02716f6f --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphSendMessageDialog.tsx @@ -0,0 +1,122 @@ +import { lazy, Suspense, useCallback, useState } from 'react'; + +import { + getTeamPendingRepliesState, + setTeamPendingRepliesState, +} from '@renderer/components/team/sidebar/teamSidebarUiState'; +import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { shouldClearPendingReplyForOpenCodeRuntimeDelivery } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; +import { useShallow } from 'zustand/react/shallow'; + +const SendMessageDialog = lazy(() => + import('@renderer/components/team/dialogs/SendMessageDialog').then((m) => ({ + default: m.SendMessageDialog, + })) +); + +interface UseGraphSendMessageDialogResult { + dialog: React.ReactNode; + openSendMessage: (memberName?: string) => void; +} + +function writePendingReply(teamName: string, memberName: string, sentAtMs: number): void { + setTeamPendingRepliesState(teamName, { + ...getTeamPendingRepliesState(teamName), + [memberName]: sentAtMs, + }); +} + +function clearPendingReply(teamName: string, memberName: string, sentAtMs: number): void { + const previous = getTeamPendingRepliesState(teamName); + if (previous[memberName] !== sentAtMs) return; + const next = { ...previous }; + delete next[memberName]; + setTeamPendingRepliesState(teamName, next); +} + +export function useGraphSendMessageDialog(teamName: string): UseGraphSendMessageDialogResult { + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const { + activeMembers, + isTeamAlive, + lastSendMessageResult, + sendDebugDetails, + sendError, + sendTeamMessage, + sendWarning, + sending, + } = useStore( + useShallow((state) => { + const teamData = selectTeamDataForName(state, teamName); + return { + activeMembers: selectResolvedMembersForTeamName(state, teamName).filter( + (member) => !member.removedAt + ), + isTeamAlive: teamData?.isAlive, + lastSendMessageResult: state.lastSendMessageResult, + sendDebugDetails: state.sendMessageDebugDetails, + sendError: state.sendMessageError, + sendTeamMessage: state.sendTeamMessage, + sendWarning: state.sendMessageWarning, + sending: state.sendingMessage, + }; + }) + ); + + const openSendMessage = useCallback((memberName?: string): void => { + setSendDialogRecipient(memberName); + setSendDialogOpen(true); + }, []); + + const closeSendMessage = useCallback((): void => { + setSendDialogOpen(false); + setSendDialogRecipient(undefined); + }, []); + + return { + openSendMessage, + dialog: sendDialogOpen ? ( + + { + const sentAtMs = Date.now(); + writePendingReply(teamName, member, sentAtMs); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if (shouldClearPendingReplyForOpenCodeRuntimeDelivery(result?.runtimeDelivery)) { + clearPendingReply(teamName, member, sentAtMs); + } + return result; + } catch (error) { + clearPendingReply(teamName, member, sentAtMs); + throw error; + } + }} + onClose={closeSendMessage} + /> + + ) : null, + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx b/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx new file mode 100644 index 00000000..ab2ee7f3 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphSurfaceInteractions.tsx @@ -0,0 +1,88 @@ +import { useMemo } from 'react'; + +import { useGraphChangeReviewDialog } from './useGraphChangeReviewDialog'; +import { useGraphCreateTaskDialog } from './useGraphCreateTaskDialog'; +import { useGraphMemberDetailDialog } from './useGraphMemberDetailDialog'; +import { useGraphSendMessageDialog } from './useGraphSendMessageDialog'; +import { useGraphTaskActions } from './useGraphTaskActions'; +import { useGraphTaskDetailDialog } from './useGraphTaskDetailDialog'; + +import type { + MemberActivityFilter, + MemberDetailTab, +} from '@renderer/components/team/members/memberDetailTypes'; + +interface OpenProfileOptions { + initialActivityFilter?: MemberActivityFilter; + initialTab?: MemberDetailTab; +} + +export function useGraphSurfaceInteractions(teamName: string): { + dialogs: React.ReactNode; + onApproveTask: (taskId: string) => void; + onCancelTask: (taskId: string) => void; + onCompleteTask: (taskId: string) => void; + onDeleteTask: (taskId: string) => void; + onMoveBackToDone: (taskId: string) => void; + onRequestChanges: (taskId: string) => void; + onRequestReview: (taskId: string) => void; + onStartTask: (taskId: string) => void; + openCreateTask: (owner?: string) => void; + openMemberProfile: (memberName: string, options?: OpenProfileOptions) => void; + openSendMessage: (memberName?: string) => void; + openTaskChanges: (taskId: string, filePath?: string) => void; + openTaskDetail: (taskId: string) => void; +} { + const changeReview = useGraphChangeReviewDialog(teamName); + const createTask = useGraphCreateTaskDialog(teamName); + const sendMessage = useGraphSendMessageDialog(teamName); + const taskActions = useGraphTaskActions(teamName); + const taskDetail = useGraphTaskDetailDialog(teamName, { + onDeleteTask: taskActions.onDeleteTask, + onViewChanges: changeReview.openTaskChanges, + }); + const memberDetail = useGraphMemberDetailDialog(teamName, { + onAssignTask: createTask.openCreateTaskDialog, + onSendMessage: sendMessage.openSendMessage, + onTaskClick: taskDetail.openTaskDetail, + onViewMemberChanges: changeReview.openMemberChanges, + }); + + const dialogs = useMemo( + () => ( + <> + {createTask.dialog} + {sendMessage.dialog} + {taskActions.dialog} + {taskDetail.dialog} + {memberDetail.dialog} + {changeReview.dialog} + + ), + [ + changeReview.dialog, + createTask.dialog, + memberDetail.dialog, + sendMessage.dialog, + taskActions.dialog, + taskDetail.dialog, + ] + ); + + return { + dialogs, + onApproveTask: taskActions.onApproveTask, + onCancelTask: taskActions.onCancelTask, + onCompleteTask: taskActions.onCompleteTask, + onDeleteTask: taskActions.onDeleteTask, + onMoveBackToDone: taskActions.onMoveBackToDone, + onRequestChanges: taskActions.onRequestChanges, + onRequestReview: taskActions.onRequestReview, + onStartTask: taskActions.onStartTask, + openCreateTask: createTask.openCreateTaskDialog, + openMemberProfile: memberDetail.openMemberProfile, + openSendMessage: sendMessage.openSendMessage, + openTaskChanges: changeReview.openTaskChanges, + openTaskDetail: taskDetail.openTaskDetail, + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx new file mode 100644 index 00000000..32939b40 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx @@ -0,0 +1,246 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { confirm } from '@renderer/components/common/ConfirmDialog'; +import { ReviewDialog } from '@renderer/components/team/dialogs/ReviewDialog'; +import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { useShallow } from 'zustand/react/shallow'; + +import type { TaskRef } from '@shared/types'; + +interface GraphTaskActionHandlers { + onApproveTask: (taskId: string) => void; + onCancelTask: (taskId: string) => void; + onCompleteTask: (taskId: string) => void; + onDeleteTask: (taskId: string) => void; + onMoveBackToDone: (taskId: string) => void; + onRequestChanges: (taskId: string) => void; + onRequestReview: (taskId: string) => void; + onStartTask: (taskId: string) => void; +} + +interface UseGraphTaskActionsResult extends GraphTaskActionHandlers { + dialog: React.ReactNode; + taskActionHandlers: GraphTaskActionHandlers; +} + +export function useGraphTaskActions(teamName: string): UseGraphTaskActionsResult { + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const { + teamData, + members, + requestReview, + sendTeamMessage, + softDeleteTask, + startTaskByUser, + updateKanban, + updateTaskStatus, + } = useStore( + useShallow((state) => ({ + teamData: selectTeamDataForName(state, teamName), + members: selectResolvedMembersForTeamName(state, teamName), + requestReview: state.requestReview, + sendTeamMessage: state.sendTeamMessage, + softDeleteTask: state.softDeleteTask, + startTaskByUser: state.startTaskByUser, + updateKanban: state.updateKanban, + updateTaskStatus: state.updateTaskStatus, + })) + ); + + const onStartTask = useCallback( + (taskId: string): void => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (!teamData?.isAlive) return; + + const task = teamData.tasks.find((candidate) => candidate.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + return; + } + + if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort notification + } + } catch { + // error via store + } + })(); + }, + [startTaskByUser, teamData, teamName] + ); + + const onCompleteTask = useCallback( + (taskId: string): void => { + void updateTaskStatus(teamName, taskId, 'completed').catch(() => undefined); + }, + [teamName, updateTaskStatus] + ); + + const onApproveTask = useCallback( + (taskId: string): void => { + void updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }).catch( + () => undefined + ); + }, + [teamName, updateKanban] + ); + + const onRequestReview = useCallback( + (taskId: string): void => { + void requestReview(teamName, taskId).catch(() => undefined); + }, + [requestReview, teamName] + ); + + const onRequestChanges = useCallback((taskId: string): void => { + setRequestChangesTaskId(taskId); + }, []); + + const onCancelTask = useCallback( + (taskId: string): void => { + void (async () => { + try { + const task = teamData?.tasks.find((candidate) => candidate.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + if (task?.owner) { + try { + await sendTeamMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort notification + } + } + + if (teamData?.isAlive) { + try { + const ownerSuffix = task?.owner ? ` ${task.owner} has been notified to stop.` : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort notification + } + } + } catch { + // error via store + } + })(); + }, + [sendTeamMessage, teamData, teamName, updateTaskStatus] + ); + + const onMoveBackToDone = useCallback( + (taskId: string): void => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }, + [teamName, updateKanban, updateTaskStatus] + ); + + const onDeleteTask = useCallback( + (taskId: string): void => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (!confirmed) return; + + await softDeleteTask(teamName, taskId).catch(() => undefined); + })(); + }, + [softDeleteTask, teamName] + ); + + const handleSubmitRequestChanges = useCallback( + (comment?: string, taskRefs?: TaskRef[]): void => { + if (!requestChangesTaskId) return; + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); + } catch { + // error via store + } + })(); + }, + [requestChangesTaskId, teamName, updateKanban] + ); + + const taskActionHandlers = useMemo( + () => ({ + onApproveTask, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onStartTask, + }), + [ + onApproveTask, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onStartTask, + ] + ); + + return { + ...taskActionHandlers, + taskActionHandlers, + dialog: ( + setRequestChangesTaskId(null)} + onSubmit={handleSubmitRequestChanges} + /> + ), + }; +} diff --git a/src/features/agent-graph/renderer/hooks/useGraphTaskDetailDialog.tsx b/src/features/agent-graph/renderer/hooks/useGraphTaskDetailDialog.tsx new file mode 100644 index 00000000..300ca8c0 --- /dev/null +++ b/src/features/agent-graph/renderer/hooks/useGraphTaskDetailDialog.tsx @@ -0,0 +1,78 @@ +import { lazy, Suspense, useCallback, useMemo, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { + selectResolvedMembersForTeamName, + selectTeamDataForName, +} from '@renderer/store/slices/teamSlice'; +import { useShallow } from 'zustand/react/shallow'; + +const TaskDetailDialog = lazy(() => + import('@renderer/components/team/dialogs/TaskDetailDialog').then((m) => ({ + default: m.TaskDetailDialog, + })) +); + +interface UseGraphTaskDetailDialogInput { + onDeleteTask?: (taskId: string) => void; + onViewChanges?: (taskId: string, filePath?: string) => void; +} + +interface UseGraphTaskDetailDialogResult { + dialog: React.ReactNode; + openTaskDetail: (taskId: string) => void; +} + +export function useGraphTaskDetailDialog( + teamName: string, + { onDeleteTask, onViewChanges }: UseGraphTaskDetailDialogInput +): UseGraphTaskDetailDialogResult { + const [selectedTaskId, setSelectedTaskId] = useState(null); + const { activeMembers, teamData, updateTaskOwner } = useStore( + useShallow((state) => ({ + activeMembers: selectResolvedMembersForTeamName(state, teamName).filter( + (member) => !member.removedAt + ), + teamData: selectTeamDataForName(state, teamName), + updateTaskOwner: state.updateTaskOwner, + })) + ); + + const taskMap = useMemo( + () => new Map((teamData?.tasks ?? []).map((task) => [task.id, task])), + [teamData?.tasks] + ); + const selectedTask = selectedTaskId ? (taskMap.get(selectedTaskId) ?? null) : null; + + const openTaskDetail = useCallback((taskId: string): void => { + setSelectedTaskId(taskId); + }, []); + + const closeTaskDetail = useCallback((): void => { + setSelectedTaskId(null); + }, []); + + return { + openTaskDetail, + dialog: + selectedTaskId && teamData ? ( + + { + void updateTaskOwner(teamName, taskId, owner).catch(() => undefined); + }} + onViewChanges={onViewChanges} + onDeleteTask={onDeleteTask} + /> + + ) : null, + }; +} diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index f4fbc58b..c2fdda0a 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -31,6 +31,7 @@ const NEW_LOG_HIGHLIGHT_MS = 1_000; const COMPACT_ROW_TITLE_LIMIT = 24; const COMPACT_ROW_TEXT_LIMIT = 76; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40; +const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto'; interface StableRectLike { left: number; @@ -427,7 +428,7 @@ export const GraphMemberLogPreviewHud = ({ const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); - shell.style.pointerEvents = 'auto'; + shell.style.pointerEvents = 'none'; shell.style.left = `${Math.round(laneRect.left)}px`; shell.style.top = `${Math.round(laneRect.top)}px`; shell.style.width = `${Math.round(laneRect.width)}px`; @@ -544,7 +545,7 @@ export const GraphMemberLogPreviewHud = ({ key={item.id} type="button" className={[ - 'block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500', + `${INTERACTIVE_LOG_CONTROL_CLASS} block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500`, rowStateClassName, ].join(' ')} title={titleText} @@ -593,7 +594,7 @@ export const GraphMemberLogPreviewHud = ({ ref={(element) => { shellRefs.current.set(node.id, element); }} - className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0" + className="pointer-events-none absolute z-10 origin-top-left select-none opacity-0" style={{ width: `${laneWidth}px`, maxWidth: `${laneWidth}px`, @@ -614,7 +615,7 @@ export const GraphMemberLogPreviewHud = ({ ) : isEmptyLoading ? ( + + + - Reject all changes across all files + + {canRejectAll + ? 'Reject all safely rejectable changes across all files' + : 'No pending files have a safe original baseline to reject.'} + )} diff --git a/src/renderer/components/team/review/reviewContentPreview.ts b/src/renderer/components/team/review/reviewContentPreview.ts new file mode 100644 index 00000000..001dc406 --- /dev/null +++ b/src/renderer/components/team/review/reviewContentPreview.ts @@ -0,0 +1,99 @@ +import type { FileChangeWithContent } from '@shared/types'; +import type { FileChangeSummary } from '@shared/types/review'; + +export type ReviewRejectBlockReason = + | 'missing-on-disk' + | 'content-unavailable' + | 'manual-ledger-review' + | 'baseline-unavailable'; + +type ReviewContentAvailability = Pick< + FileChangeWithContent, + 'contentSource' | 'originalFullContent' | 'modifiedFullContent' +>; + +export function hasReviewSnippetText(file: Pick): boolean { + return file.snippets.some( + (snippet) => !snippet.isError && (snippet.oldString.length > 0 || snippet.newString.length > 0) + ); +} + +export function getLastWriteSnippetContent( + file: Pick +): string | null { + const writeSnippets = file.snippets.filter( + (snippet) => + !snippet.isError && (snippet.type === 'write-new' || snippet.type === 'write-update') + ); + if (writeSnippets.length === 0) return null; + return writeSnippets[writeSnippets.length - 1]?.newString ?? null; +} + +export function getResolvedReviewModifiedContent( + file: Pick, + fileContent: Pick | null +): string | null { + return fileContent?.modifiedFullContent ?? getLastWriteSnippetContent(file); +} + +export function isReviewFileMissingOnDisk( + fileContent: Pick | null +): boolean { + return fileContent ? fileContent.modifiedFullContent == null : false; +} + +export function isReviewTextContentUnavailable( + file: Pick, + fileContent: Pick | null +): boolean { + return ( + fileContent?.contentSource === 'unavailable' && + getResolvedReviewModifiedContent(file, fileContent) === null + ); +} + +export function requiresManualLedgerReview(file: Pick): boolean { + return file.snippets.some( + (snippet) => + !!snippet.ledger && + (!!snippet.ledger.beforeState?.unavailableReason || + !!snippet.ledger.afterState?.unavailableReason) && + (snippet.ledger.originalFullContent == null || snippet.ledger.modifiedFullContent == null) + ); +} + +export function getReviewRejectBlockReason( + file: Pick, + fileContent: ReviewContentAvailability | null +): ReviewRejectBlockReason | null { + if (isReviewFileMissingOnDisk(fileContent)) return 'missing-on-disk'; + if (isReviewTextContentUnavailable(file, fileContent)) return 'content-unavailable'; + if (requiresManualLedgerReview(file)) return 'manual-ledger-review'; + + if (!fileContent) { + return file.snippets.length > 0 && !hasReviewSnippetText(file) ? 'baseline-unavailable' : null; + } + + const modified = getResolvedReviewModifiedContent(file, fileContent); + if (modified == null) return 'baseline-unavailable'; + if (file.isNewFile) return fileContent.originalFullContent === '' ? null : 'baseline-unavailable'; + return fileContent.originalFullContent == null ? 'baseline-unavailable' : null; +} + +export function isReviewRejectable( + file: Pick, + fileContent: ReviewContentAvailability | null +): boolean { + return getReviewRejectBlockReason(file, fileContent) === null; +} + +export function shouldRenderCurrentDiskContextPreview( + file: Pick, + fileContent: ReviewContentAvailability | null +): boolean { + return ( + fileContent?.contentSource === 'disk-current' && + fileContent.modifiedFullContent != null && + getReviewRejectBlockReason(file, fileContent) === 'baseline-unavailable' + ); +} diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts index 7d179d0e..419a5527 100644 --- a/test/main/services/team/TaskBoundaryParser.test.ts +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -263,4 +263,71 @@ describe('TaskBoundaryParser', () => { expect(result.boundaries.map((entry) => entry.taskId)).toEqual(['task-123', 'task-123']); expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); }); + + it('includes every metadata changes path in scoped file paths', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'metadata-changes.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-start', + name: 'task_start', + input: { taskId: 'task-123' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:01:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-edit', + name: 'Edit', + input: { + file_path: '/repo/dfdf/calc.js', + changes: [ + { path: '/repo/dfdf/calc.js', kind: 'add' }, + { path: '/repo/dfdf/style.css', kind: 'add' }, + ], + }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:02:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-complete', + name: 'task_complete', + input: { taskId: 'task-123' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.scopes[0]?.toolUseIds).toEqual(['tool-edit']); + expect(result.scopes[0]?.filePaths).toEqual(['/repo/dfdf/calc.js', '/repo/dfdf/style.css']); + }); }); diff --git a/test/main/services/team/TaskChangeComputer.test.ts b/test/main/services/team/TaskChangeComputer.test.ts index c0cd50fe..e3746d5e 100644 --- a/test/main/services/team/TaskChangeComputer.test.ts +++ b/test/main/services/team/TaskChangeComputer.test.ts @@ -14,9 +14,14 @@ async function writeJsonl(filePath: string, entries: object[]): Promise { ); } -function writeToolUse(toolUseId: string, filePath: string, content: string): object { +function writeToolUse( + toolUseId: string, + filePath: string, + content: string, + timestamp = '2026-03-01T10:00:00.000Z' +): object { return { - timestamp: '2026-03-01T10:00:00.000Z', + timestamp, type: 'assistant', message: { role: 'assistant', @@ -32,6 +37,52 @@ function writeToolUse(toolUseId: string, filePath: string, content: string): obj }; } +function metadataOnlyEditToolUse(toolUseId: string, filePath: string): object { + return { + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolUseId, + name: 'Edit', + input: { + file_path: filePath, + changes: [{ path: filePath, kind: 'update' }], + }, + }, + ], + }, + }; +} + +function metadataOnlyMultiFileEditToolUse( + toolUseId: string, + filePaths: string[], + primaryPath = filePaths[0] ?? '' +): object { + return { + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolUseId, + name: 'Edit', + input: { + file_path: primaryPath, + changes: filePaths.map((filePath) => ({ path: filePath, kind: 'add' })), + }, + }, + ], + }, + }; +} + describe('TaskChangeComputer', () => { let tmpDir: string | null = null; @@ -90,4 +141,356 @@ describe('TaskChangeComputer', () => { .sort((left, right) => left.localeCompare(right)) ).toEqual(['src/a.ts', 'src/b.ts']); }); + + it('does not pull unrelated log changes into a precise task scope with no file edits', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const leadLogPath = path.join(tmpDir, 'lead.jsonl'); + const memberLogPath = path.join(tmpDir, 'alice.jsonl'); + await writeJsonl(leadLogPath, [ + writeToolUse('lead-write', '/repo/src/unrelated.ts', 'export const unrelated = true;\n'), + ]); + await writeJsonl(memberLogPath, []); + + const logsFinder = { + findLogFileRefsForTask: () => + Promise.resolve([ + { filePath: leadLogPath, memberName: 'team-lead' }, + { filePath: memberLogPath, memberName: 'alice' }, + ]), + }; + const boundaryParser = { + parseBoundaries: (filePath: string) => + Promise.resolve( + filePath === memberLogPath + ? { + boundaries: [], + scopes: [ + { + taskId: 'task-1', + memberName: '', + startLine: 1, + endLine: 1, + startTimestamp: '2026-03-01T10:00:00.000Z', + endTimestamp: '2026-03-01T10:01:00.000Z', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1, label: 'high', reason: 'Both markers found' }, + }, + ], + isSingleTaskSession: true, + detectedMechanism: 'mcp' as const, + } + : { + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + } + ), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: null, + effectiveOptions: {}, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.files).toEqual([]); + expect(result.totalFiles).toBe(0); + expect(result.confidence).toBe('high'); + }); + + it('prefers persisted workIntervals over low-confidence complete-only scopes', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const logPath = path.join(tmpDir, 'alice.jsonl'); + await writeJsonl(logPath, [ + writeToolUse( + 'outside-tool', + '/repo/src/outside.ts', + 'export const outside = true;\n', + '2026-03-01T09:55:00.000Z' + ), + writeToolUse( + 'inside-tool', + '/repo/src/inside.ts', + 'export const inside = true;\n', + '2026-03-01T10:05:00.000Z' + ), + ]); + + const logsFinder = { + findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]), + }; + const boundaryParser = { + parseBoundaries: () => + Promise.resolve({ + boundaries: [], + scopes: [ + { + taskId: 'task-1', + memberName: '', + startLine: 1, + endLine: 2, + startTimestamp: '', + endTimestamp: '2026-03-01T10:06:00.000Z', + toolUseIds: ['outside-tool', 'inside-tool'], + filePaths: ['/repo/src/outside.ts', '/repo/src/inside.ts'], + confidence: { + tier: 3, + label: 'low', + reason: 'Only complete marker found, start assumed at file beginning', + }, + }, + ], + isSingleTaskSession: true, + detectedMechanism: 'mcp' as const, + }), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: { owner: 'alice', status: 'completed' }, + effectiveOptions: { + intervals: [ + { + startedAt: '2026-03-01T10:00:00.000Z', + completedAt: '2026-03-01T10:10:00.000Z', + }, + ], + }, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.confidence).toBe('medium'); + expect(result.warnings).toEqual([ + 'Task start boundary missing - scoped by persisted workIntervals timestamps.', + ]); + expect(result.files.map((file) => file.relativePath)).toEqual(['src/inside.ts']); + expect(result.scope.toolUseIds).toEqual(['inside-tool']); + }); + + it('does not pull lead-session interval edits into a member complete-only scope', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const leadLogPath = path.join(tmpDir, 'lead.jsonl'); + const memberLogPath = path.join(tmpDir, 'alice.jsonl'); + await writeJsonl(leadLogPath, [ + writeToolUse( + 'lead-inside-tool', + '/repo/src/lead.ts', + 'export const lead = true;\n', + '2026-03-01T10:05:00.000Z' + ), + ]); + await writeJsonl(memberLogPath, [ + writeToolUse( + 'member-inside-tool', + '/repo/src/member.ts', + 'export const member = true;\n', + '2026-03-01T10:06:00.000Z' + ), + ]); + + const logsFinder = { + findLogFileRefsForTask: () => + Promise.resolve([ + { filePath: leadLogPath, memberName: 'team-lead' }, + { filePath: memberLogPath, memberName: 'alice' }, + ]), + }; + const boundaryParser = { + parseBoundaries: (filePath: string) => + Promise.resolve( + filePath === memberLogPath + ? { + boundaries: [], + scopes: [ + { + taskId: 'task-1', + memberName: '', + startLine: 1, + endLine: 1, + startTimestamp: '', + endTimestamp: '2026-03-01T10:07:00.000Z', + toolUseIds: ['member-inside-tool'], + filePaths: ['/repo/src/member.ts'], + confidence: { + tier: 3, + label: 'low', + reason: 'Only complete marker found, start assumed at file beginning', + }, + }, + ], + isSingleTaskSession: true, + detectedMechanism: 'mcp' as const, + } + : { + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + } + ), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: { owner: 'alice', status: 'completed' }, + effectiveOptions: { + intervals: [ + { + startedAt: '2026-03-01T10:00:00.000Z', + completedAt: '2026-03-01T10:10:00.000Z', + }, + ], + }, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.files.map((file) => file.relativePath)).toEqual(['src/member.ts']); + expect(result.scope.toolUseIds).toEqual(['member-inside-tool']); + }); + + it('keeps metadata-only synthetic Edit entries as file-change hints', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const logPath = path.join(tmpDir, 'agent.jsonl'); + await writeJsonl(logPath, [metadataOnlyEditToolUse('tool-1', '/repo/src/a.ts')]); + + const logsFinder = { + findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'alice' }]), + }; + const boundaryParser = { + parseBoundaries: () => + Promise.resolve({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + }), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: null, + effectiveOptions: {}, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.files.map((file) => file.relativePath)).toEqual(['src/a.ts']); + expect(result.files[0]?.snippets).toHaveLength(1); + expect(result.files[0]?.snippets[0]?.oldString).toBe(''); + expect(result.files[0]?.snippets[0]?.newString).toBe(''); + expect(result.totalFiles).toBe(1); + }); + + it('expands metadata-only Edit changes arrays into all changed file hints', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const logPath = path.join(tmpDir, 'agent.jsonl'); + await writeJsonl(logPath, [ + metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/dfdf/calc.js', '/repo/dfdf/style.css']), + ]); + + const logsFinder = { + findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'tom' }]), + }; + const boundaryParser = { + parseBoundaries: () => + Promise.resolve({ + boundaries: [], + scopes: [ + { + taskId: 'task-1', + memberName: '', + startLine: 1, + endLine: 1, + startTimestamp: '2026-03-01T10:00:00.000Z', + endTimestamp: '2026-03-01T10:01:00.000Z', + toolUseIds: ['tool-1'], + filePaths: ['/repo/dfdf/calc.js', '/repo/dfdf/style.css'], + confidence: { tier: 1, label: 'high', reason: 'Both markers found' }, + }, + ], + isSingleTaskSession: true, + detectedMechanism: 'mcp' as const, + }), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: null, + effectiveOptions: {}, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.files.map((file) => file.relativePath)).toEqual([ + 'dfdf/calc.js', + 'dfdf/style.css', + ]); + expect(result.files.every((file) => file.snippets[0]?.toolUseId === 'tool-1')).toBe(true); + expect(result.files.every((file) => file.linesAdded === 0 && file.linesRemoved === 0)).toBe( + true + ); + }); + + it('does not include repeated tool ids from outside the scoped source lines', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); + const logPath = path.join(tmpDir, 'agent.jsonl'); + await writeJsonl(logPath, [ + metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/index.html', '/repo/style.css']), + metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/177/landing.css'], '/repo/177/landing.css'), + ]); + + const logsFinder = { + findLogFileRefsForTask: () => Promise.resolve([{ filePath: logPath, memberName: 'tom' }]), + }; + const boundaryParser = { + parseBoundaries: () => + Promise.resolve({ + boundaries: [], + scopes: [ + { + taskId: 'task-1', + memberName: '', + startLine: 2, + endLine: 2, + startTimestamp: '2026-03-01T09:59:00.000Z', + endTimestamp: '2026-03-01T10:01:00.000Z', + toolUseIds: ['tool-1'], + filePaths: ['/repo/177/landing.css'], + confidence: { tier: 1, label: 'high', reason: 'Both markers found' }, + }, + ], + isSingleTaskSession: true, + detectedMechanism: 'mcp' as const, + }), + }; + const computer = new TaskChangeComputer(logsFinder as never, boundaryParser as never); + + const result = await computer.computeTaskChanges({ + teamName: 'team-a', + taskId: 'task-1', + taskMeta: null, + effectiveOptions: {}, + projectPath: '/repo', + includeDetails: true, + }); + + expect(result.files.map((file) => file.relativePath)).toEqual(['177/landing.css']); + expect(result.scope.filePaths).toEqual(['/repo/177/landing.css']); + }); }); diff --git a/test/renderer/components/team/review/reviewContentPreview.test.ts b/test/renderer/components/team/review/reviewContentPreview.test.ts new file mode 100644 index 00000000..dd863d9c --- /dev/null +++ b/test/renderer/components/team/review/reviewContentPreview.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + getReviewRejectBlockReason, + getResolvedReviewModifiedContent, + isReviewRejectable, + isReviewFileMissingOnDisk, + isReviewTextContentUnavailable, + shouldRenderCurrentDiskContextPreview, +} from '../../../../../src/renderer/components/team/review/reviewContentPreview'; + +import type { FileChangeWithContent } from '@shared/types'; +import type { FileChangeSummary } from '@shared/types/review'; + +function makeFile(overrides: Partial = {}): FileChangeSummary { + return { + filePath: '/repo/calc112/calc.js', + relativePath: 'calc112/calc.js', + snippets: [], + linesAdded: 0, + linesRemoved: 0, + isNewFile: true, + ...overrides, + }; +} + +function makeContent(overrides: Partial = {}): FileChangeWithContent { + return { + ...makeFile(), + originalFullContent: null, + modifiedFullContent: null, + contentSource: 'unavailable', + ...overrides, + }; +} + +describe('reviewContentPreview', () => { + it('uses write snippets as a restorable preview when the file is missing on disk', () => { + const file = makeFile({ + snippets: [ + { + toolUseId: 'tool-1', + filePath: '/repo/calc112/calc.js', + toolName: 'Write', + type: 'write-new', + oldString: '', + newString: 'const value = 1;\n', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + }, + ], + }); + const content = makeContent(); + + expect(isReviewFileMissingOnDisk(content)).toBe(true); + expect(getResolvedReviewModifiedContent(file, content)).toBe('const value = 1;\n'); + expect(isReviewTextContentUnavailable(file, content)).toBe(false); + }); + + it('keeps metadata-only unavailable content unavailable', () => { + const file = makeFile(); + const content = makeContent(); + + expect(getResolvedReviewModifiedContent(file, content)).toBeNull(); + expect(isReviewTextContentUnavailable(file, content)).toBe(true); + }); + + it('blocks reject for metadata-only current disk content but allows a context preview', () => { + const file = makeFile({ + isNewFile: false, + snippets: [ + { + toolUseId: 'tool-1', + filePath: '/repo/calc112/calc.js', + toolName: 'Edit', + type: 'edit', + oldString: '', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + }, + ], + }); + const content = makeContent({ + contentSource: 'disk-current', + originalFullContent: null, + modifiedFullContent: 'const value = 1;\n', + }); + + expect(getReviewRejectBlockReason(file, content)).toBe('baseline-unavailable'); + expect(isReviewRejectable(file, content)).toBe(false); + expect(shouldRenderCurrentDiskContextPreview(file, content)).toBe(true); + }); + + it('allows reject when both original and modified full text are available', () => { + const file = makeFile({ isNewFile: false }); + const content = makeContent({ + contentSource: 'snippet-reconstruction', + originalFullContent: 'const value = 1;\n', + modifiedFullContent: 'const value = 2;\n', + }); + + expect(getReviewRejectBlockReason(file, content)).toBeNull(); + expect(isReviewRejectable(file, content)).toBe(true); + }); +}); diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index 5d774564..6ee9fce8 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -209,6 +209,60 @@ describe('GraphMemberLogPreviewHud', () => { }); }); + it('lets empty log lane space pass pointer events through while rows remain clickable', async () => { + const node: GraphNode = { + id: 'member:alpha-team:alice', + kind: 'member', + label: 'alice', + state: 'active', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'alice' }, + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: 40, + top: 80, + right: 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + await Promise.resolve(); + }); + + const shell = host.querySelector('.z-10'); + expect(shell).not.toBeNull(); + expect(shell?.className).toContain('pointer-events-none'); + expect(shell?.style.pointerEvents).toBe('none'); + + const row = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('pnpm test') + ); + expect(row?.className).toContain('pointer-events-auto'); + + const moreButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('+2 more') + ); + expect(moreButton?.className).toContain('pointer-events-auto'); + + act(() => { + root.unmount(); + }); + }); + it('caps long visible rows while preserving the full preview in the title', async () => { const node: GraphNode = { id: 'member:alpha-team:alice', diff --git a/test/renderer/features/agent-graph/GraphTaskCard.test.tsx b/test/renderer/features/agent-graph/GraphTaskCard.test.tsx new file mode 100644 index 00000000..e9967631 --- /dev/null +++ b/test/renderer/features/agent-graph/GraphTaskCard.test.tsx @@ -0,0 +1,167 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TeamTaskWithKanban } from '@shared/types/team'; + +const graphActivityMock = vi.hoisted(() => ({ + teamData: null as { + tasks: TeamTaskWithKanban[]; + members: { name: string; color?: string }[]; + } | null, +})); + +vi.mock('@features/agent-graph/renderer/hooks/useGraphActivityContext', () => ({ + useGraphActivityContext: () => ({ + teamData: graphActivityMock.teamData, + }), +})); + +vi.mock('@renderer/components/team/MemberBadge', () => ({ + MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name), +})); + +vi.mock('@renderer/components/team/UnreadCommentsBadge', () => ({ + UnreadCommentsBadge: () => React.createElement('span', { 'data-testid': 'comments-badge' }), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + className, + onClick, + disabled, + 'aria-label': ariaLabel, + }: { + children: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + disabled?: boolean; + 'aria-label'?: string; + }) => + React.createElement( + 'button', + { className, onClick, disabled, 'aria-label': ariaLabel, type: 'button' }, + children + ), +})); + +vi.mock('@renderer/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: { children: React.ReactNode }) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: { children: React.ReactNode }) => + React.createElement('div', null, children), +})); + +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), +})); + +vi.mock('@renderer/hooks/useTheme', () => ({ + useTheme: () => ({ isLight: false }), +})); + +vi.mock('@renderer/hooks/useUnreadCommentCount', () => ({ + useUnreadCommentCount: () => 0, +})); + +import { GraphTaskCard } from '@features/agent-graph/renderer/ui/GraphTaskCard'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +const changedTask = { + id: 'task-1', + displayId: '#1', + subject: 'Review graph diff route', + owner: 'alice', + reviewer: '', + status: 'completed', + changePresence: 'has_changes', + comments: [], + blockedBy: [], + blocks: [], + workIntervals: [], + historyEvents: [], + createdAt: '2026-05-17T10:00:00.000Z', + updatedAt: '2026-05-17T10:10:00.000Z', +} as TeamTaskWithKanban; + +const taskNode: GraphNode = { + id: 'task:northstar-core:task-1', + kind: 'task', + label: 'Review graph diff route', + state: 'complete', + domainRef: { kind: 'task', teamName: 'northstar-core', taskId: 'task-1' }, +}; + +const noop = (): void => undefined; + +async function flushReact(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('GraphTaskCard', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + graphActivityMock.teamData = { + tasks: [changedTask], + members: [{ name: 'alice', color: 'blue' }], + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('opens task changes from the graph card and closes the popover', async () => { + const onViewChanges = vi.fn(); + const onClose = vi.fn(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + ); + await flushReact(); + }); + + const changesButton = host.querySelector('button[aria-label="Changes"]'); + expect(changesButton).not.toBeNull(); + + await act(async () => { + changesButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushReact(); + }); + + expect(onViewChanges).toHaveBeenCalledWith('task-1'); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await flushReact(); + }); + }); +});