fix: harden task change review flows

This commit is contained in:
777genius 2026-05-17 14:18:54 +03:00
parent 240bc81d0a
commit e333d09d9c
23 changed files with 2150 additions and 322 deletions

View file

@ -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<GraphChangeReviewDialogState>({
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 ? (
<Suspense fallback={null}>
<ChangeReviewDialog
open={dialogState.open}
onOpenChange={handleOpenChange}
teamName={teamName}
mode={dialogState.mode}
memberName={dialogState.memberName}
taskId={dialogState.taskId}
initialFilePath={dialogState.initialFilePath}
taskChangeRequestOptions={dialogState.taskChangeRequestOptions}
projectPath={teamData?.config.projectPath}
/>
</Suspense>
) : null,
};
}

View file

@ -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<string | null>(null);
const [selectedMemberView, setSelectedMemberView] = useState<OpenMemberProfileOptions | null>(
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 ? (
<Suspense fallback={null}>
<MemberDetailDialog
open
member={selectedMember}
teamName={teamName}
members={members}
tasks={teamData.tasks}
initialTab={selectedMemberView?.initialTab}
initialActivityFilter={selectedMemberView?.initialActivityFilter}
isTeamAlive={teamData.isAlive}
isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
spawnEntry={selectedSpawnEntry}
runtimeEntry={selectedRuntimeEntry}
runtimeRunId={runtimeRunId}
launchParams={launchParams}
onClose={closeMemberProfile}
onSendMessage={() => {
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);
}}
/>
</Suspense>
) : null,
};
}

View file

@ -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<string | undefined>(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 ? (
<Suspense fallback={null}>
<SendMessageDialog
open={sendDialogOpen}
teamName={teamName}
members={activeMembers}
defaultRecipient={sendDialogRecipient}
isTeamAlive={isTeamAlive}
sending={sending}
sendError={sendError}
sendWarning={sendWarning}
sendDebugDetails={sendDebugDetails}
lastResult={lastSendMessageResult}
onSend={async (member, text, summary, attachments, actionMode, taskRefs) => {
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}
/>
</Suspense>
) : null,
};
}

View file

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

View file

@ -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<string | null>(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<GraphTaskActionHandlers>(
() => ({
onApproveTask,
onCancelTask,
onCompleteTask,
onDeleteTask,
onMoveBackToDone,
onRequestChanges,
onRequestReview,
onStartTask,
}),
[
onApproveTask,
onCancelTask,
onCompleteTask,
onDeleteTask,
onMoveBackToDone,
onRequestChanges,
onRequestReview,
onStartTask,
]
);
return {
...taskActionHandlers,
taskActionHandlers,
dialog: (
<ReviewDialog
open={requestChangesTaskId !== null}
teamName={teamName}
taskId={requestChangesTaskId}
members={members}
onCancel={() => setRequestChangesTaskId(null)}
onSubmit={handleSubmitRequestChanges}
/>
),
};
}

View file

@ -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<string | null>(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 ? (
<Suspense fallback={null}>
<TaskDetailDialog
open
task={selectedTask}
teamName={teamName}
kanbanTaskState={teamData.kanbanState.tasks[selectedTaskId]}
taskMap={taskMap}
members={activeMembers}
onClose={closeTaskDetail}
onScrollToTask={openTaskDetail}
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner).catch(() => undefined);
}}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
</Suspense>
) : null,
};
}

View file

@ -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 ? (
<button
type="button"
className="flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60"
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60`}
aria-busy="true"
aria-label="Loading logs"
onClick={() => openLogs(memberName)}
@ -625,7 +626,7 @@ export const GraphMemberLogPreviewHud = ({
) : (
<button
type="button"
className="flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60"
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex h-[72px] min-h-[72px] items-center rounded-md border border-dashed border-white/10 bg-[rgba(8,14,28,0.28)] px-3 text-left text-[11px] text-slate-400/60`}
onClick={() => openLogs(memberName)}
>
{resolveEmptyText(preview, loading, error)}
@ -634,7 +635,7 @@ export const GraphMemberLogPreviewHud = ({
{preview && preview.overflowCount > 0 ? (
<button
type="button"
className="h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
className={`${INTERACTIVE_LOG_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => openLogs(memberName)}
>
+{preview.overflowCount} more

View file

@ -24,6 +24,10 @@ import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverCont
import { GraphTaskCard } from './GraphTaskCard';
import type { GraphNode } from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
import type { TeamTaskWithKanban } from '@shared/types';
// ─── Tool name/preview formatters ───────────────────────────────────────────
@ -74,7 +78,13 @@ interface GraphNodePopoverProps {
onClose: () => void;
onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (memberName: string) => void;
onOpenMemberProfile?: (
memberName: string,
options?: {
initialActivityFilter?: MemberActivityFilter;
initialTab?: MemberDetailTab;
}
) => void;
onCreateTask?: (owner: string) => void;
onStartTask?: (taskId: string) => void;
onCompleteTask?: (taskId: string) => void;
@ -83,6 +93,7 @@ interface GraphNodePopoverProps {
onRequestChanges?: (taskId: string) => void;
onCancelTask?: (taskId: string) => void;
onMoveBackToDone?: (taskId: string) => void;
onViewChanges?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
@ -101,6 +112,7 @@ export const GraphNodePopover = ({
onRequestChanges,
onCancelTask,
onMoveBackToDone,
onViewChanges,
onDeleteTask,
}: GraphNodePopoverProps): React.JSX.Element => {
if (node.kind === 'member' || node.kind === 'lead') {
@ -140,6 +152,7 @@ export const GraphNodePopover = ({
onRequestChanges={onRequestChanges}
onCancelTask={onCancelTask}
onMoveBackToDone={onMoveBackToDone}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
);

View file

@ -29,6 +29,7 @@ interface GraphTaskCardProps {
onRequestChanges?: (taskId: string) => void;
onCancelTask?: (taskId: string) => void;
onMoveBackToDone?: (taskId: string) => void;
onViewChanges?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
@ -80,6 +81,7 @@ export const GraphTaskCard = ({
onRequestChanges,
onCancelTask,
onMoveBackToDone,
onViewChanges,
onDeleteTask,
}: GraphTaskCardProps): React.JSX.Element => {
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
@ -143,6 +145,7 @@ export const GraphTaskCard = ({
onRequestChanges={closeAct(onRequestChanges)}
onCancelTask={closeAct(onCancelTask)}
onMoveBackToDone={closeAct(onMoveBackToDone)}
onViewChanges={onViewChanges ? closeAct(onViewChanges) : undefined}
onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined}
/>
</div>

View file

@ -3,14 +3,14 @@
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
*/
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
@ -26,10 +26,6 @@ import type {
GraphEventPort,
TransientHandoffCard,
} from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
export interface TeamGraphOverlayProps {
teamName: string;
@ -38,15 +34,6 @@ export interface TeamGraphOverlayProps {
sidebarVisible?: boolean;
onToggleSidebar?: () => void;
messagesPanelEnabled?: boolean;
onSendMessage?: (memberName: string) => void;
onOpenTaskDetail?: (taskId: string) => void;
onOpenMemberProfile?: (
memberName: string,
options?: {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
) => void;
}
export const TeamGraphOverlay = ({
@ -56,9 +43,6 @@ export const TeamGraphOverlay = ({
sidebarVisible,
onToggleSidebar,
messagesPanelEnabled = true,
onSendMessage,
onOpenTaskDetail,
onOpenMemberProfile,
}: TeamGraphOverlayProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName);
const {
@ -69,7 +53,7 @@ export const TeamGraphOverlay = ({
} = useTeamGraphSurfaceActions(teamName);
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
const interactions = useGraphSurfaceInteractions(teamName);
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
null
);
@ -79,55 +63,29 @@ export const TeamGraphOverlay = ({
teamName,
enabled: messagesPanelEnabled,
mountPoint: messagesPanelMountPoint,
onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName),
onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId),
onOpenMemberProfile: interactions.openMemberProfile,
onOpenTaskDetail: interactions.openTaskDetail,
});
// Task action dispatchers (same pattern as TeamGraphTab)
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
[teamName]
);
const taskActions = useMemo(
() => ({
onStartTask: dispatchTaskAction('start-task'),
onCompleteTask: dispatchTaskAction('complete-task'),
onApproveTask: dispatchTaskAction('approve-task'),
onRequestReview: dispatchTaskAction('request-review'),
onRequestChanges: dispatchTaskAction('request-changes'),
onCancelTask: dispatchTaskAction('cancel-task'),
onMoveBackToDone: dispatchTaskAction('move-back-to-done'),
onDeleteTask: dispatchTaskAction('delete-task'),
}),
[dispatchTaskAction]
);
const openTeamPage = useCallback(() => {
openTeamTab();
onClose();
}, [onClose, openTeamTab]);
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
interactions.openCreateTask('');
}, [interactions]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
if (ref.kind === 'task') onOpenTaskDetail?.(ref.taskId);
else if (ref.kind === 'member') onOpenMemberProfile?.(ref.memberName);
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
},
[onOpenTaskDetail, onOpenMemberProfile]
),
onSendMessage: useCallback(
(memberName: string) => onSendMessage?.(memberName),
[onSendMessage]
),
onOpenTaskDetail: useCallback(
(taskId: string) => onOpenTaskDetail?.(taskId),
[onOpenTaskDetail]
[interactions]
),
onSendMessage: interactions.openSendMessage,
onOpenTaskDetail: interactions.openTaskDetail,
onOpenMemberProfile: useCallback(
(memberName: string) => onOpenMemberProfile?.(memberName),
[onOpenMemberProfile]
(memberName: string) => interactions.openMemberProfile(memberName),
[interactions]
),
};
@ -205,8 +163,8 @@ export const TeamGraphOverlay = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={filters?.showActivity ?? true}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
onOpenTaskDetail={interactions.openTaskDetail}
onOpenMemberProfile={interactions.openMemberProfile}
/>
<GraphMemberLogPreviewHud
teamName={teamName}
@ -217,7 +175,7 @@ export const TeamGraphOverlay = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={filters?.showLogs ?? true}
onOpenMemberProfile={onOpenMemberProfile}
onOpenMemberProfile={interactions.openMemberProfile}
/>
</>
);
@ -230,7 +188,7 @@ export const TeamGraphOverlay = ({
targetNode={targetNode}
onClose={closeEdge}
onSelectNode={onSelectNode}
onOpenTaskDetail={onOpenTaskDetail}
onOpenTaskDetail={interactions.openTaskDetail}
/>
)}
renderOverlay={({ node, onClose: closePopover }) => (
@ -239,19 +197,27 @@ export const TeamGraphOverlay = ({
teamName={teamName}
onClose={closePopover}
onSendMessage={(name) => {
onSendMessage?.(name);
interactions.openSendMessage(name);
closePopover();
}}
onCreateTask={openCreateTaskDialog}
onCreateTask={interactions.openCreateTask}
onOpenTaskDetail={(id) => {
onOpenTaskDetail?.(id);
interactions.openTaskDetail(id);
closePopover();
}}
onOpenMemberProfile={(name) => {
onOpenMemberProfile?.(name);
onOpenMemberProfile={(name, options) => {
interactions.openMemberProfile(name, options);
closePopover();
}}
{...taskActions}
onStartTask={interactions.onStartTask}
onCompleteTask={interactions.onCompleteTask}
onApproveTask={interactions.onApproveTask}
onRequestReview={interactions.onRequestReview}
onRequestChanges={interactions.onRequestChanges}
onCancelTask={interactions.onCancelTask}
onMoveBackToDone={interactions.onMoveBackToDone}
onViewChanges={interactions.openTaskChanges}
onDeleteTask={interactions.onDeleteTask}
/>
)}
/>
@ -262,7 +228,7 @@ export const TeamGraphOverlay = ({
/>
) : null}
{graphMessagesPanel}
{createTaskDialog}
{interactions.dialogs}
</div>
);
};

View file

@ -3,14 +3,14 @@
* Provides Fullscreen button that opens the overlay.
*/
import { lazy, Suspense, useCallback, useMemo, useState } from 'react';
import { lazy, Suspense, useCallback, useState } from 'react';
import { GraphView } from '@claude-teams/agent-graph';
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
@ -26,10 +26,6 @@ import type {
GraphEventPort,
TransientHandoffCard,
} from '@claude-teams/agent-graph';
import type {
MemberActivityFilter,
MemberDetailTab,
} from '@renderer/components/team/members/memberDetailTypes';
const TeamGraphOverlay = lazy(() =>
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
@ -41,11 +37,6 @@ export interface TeamGraphTabProps {
isPaneFocused?: boolean;
}
interface OpenProfileOptions {
initialTab?: MemberDetailTab;
initialActivityFilter?: MemberActivityFilter;
}
export const TeamGraphTab = ({
teamName,
isActive = true,
@ -59,86 +50,34 @@ export const TeamGraphTab = ({
null
);
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
// Typed event dispatchers (DRY — used in both events + renderOverlay)
const dispatchOpenTask = useCallback(
(taskId: string) =>
window.dispatchEvent(new CustomEvent('graph:open-task', { detail: { teamName, taskId } })),
[teamName]
);
const dispatchSendMessage = useCallback(
(memberName: string) =>
window.dispatchEvent(
new CustomEvent('graph:send-message', { detail: { teamName, memberName } })
),
[teamName]
);
const dispatchOpenProfile = useCallback(
(memberName: string, options?: OpenProfileOptions) =>
window.dispatchEvent(
new CustomEvent('graph:open-profile', {
detail: { teamName, memberName, ...options },
})
),
[teamName]
);
const interactions = useGraphSurfaceInteractions(teamName);
const openCreateTask = useCallback(() => {
openCreateTaskDialog('');
}, [openCreateTaskDialog]);
// Task action dispatchers
const dispatchTaskAction = useCallback(
(action: string) => (taskId: string) =>
window.dispatchEvent(new CustomEvent(`graph:${action}`, { detail: { teamName, taskId } })),
[teamName]
);
const dispatchStartTask = useMemo(() => dispatchTaskAction('start-task'), [dispatchTaskAction]);
const dispatchCompleteTask = useMemo(
() => dispatchTaskAction('complete-task'),
[dispatchTaskAction]
);
const dispatchApproveTask = useMemo(
() => dispatchTaskAction('approve-task'),
[dispatchTaskAction]
);
const dispatchRequestReview = useMemo(
() => dispatchTaskAction('request-review'),
[dispatchTaskAction]
);
const dispatchRequestChanges = useMemo(
() => dispatchTaskAction('request-changes'),
[dispatchTaskAction]
);
const dispatchCancelTask = useMemo(() => dispatchTaskAction('cancel-task'), [dispatchTaskAction]);
const dispatchMoveBackToDone = useMemo(
() => dispatchTaskAction('move-back-to-done'),
[dispatchTaskAction]
);
const dispatchDeleteTask = useMemo(() => dispatchTaskAction('delete-task'), [dispatchTaskAction]);
interactions.openCreateTask('');
}, [interactions]);
const events: GraphEventPort = {
onNodeDoubleClick: useCallback(
(ref: GraphDomainRef) => {
if (ref.kind === 'task') dispatchOpenTask(ref.taskId);
else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName);
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
},
[dispatchOpenTask, dispatchOpenProfile]
[interactions]
),
onSendMessage: dispatchSendMessage,
onOpenTaskDetail: dispatchOpenTask,
onSendMessage: interactions.openSendMessage,
onOpenTaskDetail: interactions.openTaskDetail,
onOpenMemberProfile: useCallback(
(memberName: string) => {
dispatchOpenProfile(memberName);
interactions.openMemberProfile(memberName);
},
[dispatchOpenProfile]
[interactions]
),
};
const graphMessagesPanel = useGraphMessagesPanel({
teamName,
enabled: isActive && isPaneFocused && !fullscreen,
mountPoint: messagesPanelMountPoint,
onOpenMemberProfile: dispatchOpenProfile,
onOpenTaskDetail: dispatchOpenTask,
onOpenMemberProfile: interactions.openMemberProfile,
onOpenTaskDetail: interactions.openTaskDetail,
});
return (
@ -224,8 +163,8 @@ export const TeamGraphTab = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={isActive && (filters?.showActivity ?? true)}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onOpenTaskDetail={interactions.openTaskDetail}
onOpenMemberProfile={interactions.openMemberProfile}
/>
<GraphMemberLogPreviewHud
teamName={teamName}
@ -236,7 +175,7 @@ export const TeamGraphTab = ({
getViewportSize={getViewportSize}
focusNodeIds={focusNodeIds}
enabled={isActive && (filters?.showLogs ?? true)}
onOpenMemberProfile={dispatchOpenProfile}
onOpenMemberProfile={interactions.openMemberProfile}
/>
</>
);
@ -249,7 +188,7 @@ export const TeamGraphTab = ({
targetNode={targetNode}
onClose={onClose}
onSelectNode={onSelectNode}
onOpenTaskDetail={dispatchOpenTask}
onOpenTaskDetail={interactions.openTaskDetail}
/>
)}
renderOverlay={({ node, onClose }) => (
@ -257,18 +196,19 @@ export const TeamGraphTab = ({
node={node}
teamName={teamName}
onClose={onClose}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
onCreateTask={openCreateTaskDialog}
onStartTask={dispatchStartTask}
onCompleteTask={dispatchCompleteTask}
onApproveTask={dispatchApproveTask}
onRequestReview={dispatchRequestReview}
onRequestChanges={dispatchRequestChanges}
onCancelTask={dispatchCancelTask}
onMoveBackToDone={dispatchMoveBackToDone}
onDeleteTask={dispatchDeleteTask}
onSendMessage={interactions.openSendMessage}
onOpenTaskDetail={interactions.openTaskDetail}
onOpenMemberProfile={interactions.openMemberProfile}
onCreateTask={interactions.openCreateTask}
onStartTask={interactions.onStartTask}
onCompleteTask={interactions.onCompleteTask}
onApproveTask={interactions.onApproveTask}
onRequestReview={interactions.onRequestReview}
onRequestChanges={interactions.onRequestChanges}
onCancelTask={interactions.onCancelTask}
onMoveBackToDone={interactions.onMoveBackToDone}
onViewChanges={interactions.openTaskChanges}
onDeleteTask={interactions.onDeleteTask}
/>
)}
/>
@ -280,7 +220,7 @@ export const TeamGraphTab = ({
/>
) : null}
{graphMessagesPanel}
{createTaskDialog}
{interactions.dialogs}
{fullscreen && (
<Suspense fallback={null}>
<TeamGraphOverlay
@ -288,9 +228,6 @@ export const TeamGraphTab = ({
onClose={() => setFullscreen(false)}
sidebarVisible={sidebarVisible}
onToggleSidebar={toggleSidebarVisible}
onSendMessage={dispatchSendMessage}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
messagesPanelEnabled={isActive && isPaneFocused}
/>
</Suspense>

View file

@ -33,7 +33,7 @@ interface BoundaryCacheEntry {
interface ToolUseInfo {
toolUseId: string;
toolName: string;
filePath?: string;
filePaths: string[];
}
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
@ -126,9 +126,9 @@ export class TaskBoundaryParser {
const toolName = canonicalizeAgentTeamsToolName(rawName);
const toolUseId = typeof b.id === 'string' ? b.id : '';
const input = b.input as Record<string, unknown> | undefined;
const fp = typeof input?.file_path === 'string' ? input.file_path : undefined;
const filePaths = input ? this.extractFilePaths(input) : [];
if (!allToolUsesByLine.has(lineNumber)) allToolUsesByLine.set(lineNumber, []);
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePaths });
}
// Prefer structured task markers for modern runtime sessions.
@ -197,6 +197,28 @@ export class TaskBoundaryParser {
return null;
}
private extractFilePaths(input: Record<string, unknown>): string[] {
const paths: string[] = [];
const seen = new Set<string>();
const addPath = (value: unknown): void => {
if (typeof value !== 'string' || value.length === 0) return;
const normalized = value.replace(/\\/g, '/');
if (seen.has(normalized)) return;
seen.add(normalized);
paths.push(value);
};
addPath(input.file_path);
const changes = Array.isArray(input.changes) ? input.changes : [];
for (const change of changes) {
if (!change || typeof change !== 'object') continue;
addPath((change as Record<string, unknown>).path);
}
return paths;
}
/**
* Найти TaskUpdate/proxy_TaskUpdate tool_use блоки.
* status: in_progress start, completed complete
@ -386,7 +408,9 @@ export class TaskBoundaryParser {
for (const tool of tools) {
if (FILE_MODIFYING_TOOLS.has(tool.toolName) && tool.toolUseId) {
toolUseIds.push(tool.toolUseId);
if (tool.filePath) filePaths.add(tool.filePath);
for (const filePath of tool.filePaths) {
filePaths.add(filePath);
}
}
}
}

View file

@ -22,21 +22,35 @@ import type {
const logger = createLogger('Service:TaskChangeComputer');
interface ParsedSnippetsCacheEntry {
data: SnippetDiff[];
data: ParsedSnippetRecord[];
mtime: number;
expiresAt: number;
}
interface ParsedSnippetsResult {
snippets: SnippetDiff[];
snippets: ParsedSnippetRecord[];
mtime: number;
}
interface ParsedSnippetRecord {
snippet: SnippetDiff;
sourceLine: number;
}
interface LogFileRef {
filePath: string;
memberName: string;
}
interface MetadataChangePath {
filePath: string;
}
interface ParsedJsonlEntry {
entry: Record<string, unknown>;
lineNumber: number;
}
export class TaskChangeComputer {
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
@ -60,7 +74,7 @@ export class TaskChangeComputer {
const merged: SnippetDiff[] = [];
for (const result of parseResults) {
merged.push(...result.snippets);
merged.push(...result.snippets.map((record) => record.snippet));
if (result.mtime > latestMtime) {
latestMtime = result.mtime;
}
@ -101,54 +115,43 @@ export class TaskChangeComputer {
}
if (allScopes.length === 0) {
const intervals = effectiveOptions.intervals;
if (Array.isArray(intervals) && intervals.length > 0) {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
return {
teamName,
taskId,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: 'medium',
computedAt: new Date().toISOString(),
scope: {
taskId,
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
startLine: 0,
endLine: 0,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: files.map((file) => file.filePath),
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings:
files.length === 0
? ['No file edits found within persisted workIntervals.']
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
};
}
const intervalScoped = await this.buildIntervalScopedTaskChangeSet({
teamName,
taskId,
taskMeta,
logRefs,
intervals: effectiveOptions.intervals,
projectPath,
includeDetails,
warningWithFiles: 'Task boundaries missing - scoped by workIntervals timestamps.',
warningWithoutFiles: 'No file edits found within persisted workIntervals.',
});
if (intervalScoped) return intervalScoped;
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
}
const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds));
const files = await this.extractFilteredChanges(
logRefs,
allowedToolUseIds,
projectPath,
includeDetails
);
const files = await this.extractScopedChanges(logRefs, allScopes, projectPath, includeDetails);
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
if (worstTier >= 3) {
const intervalScoped = await this.buildIntervalScopedTaskChangeSet({
teamName,
taskId,
taskMeta,
logRefs: this.selectScopedLogRefs(logRefs, allScopes),
intervals: effectiveOptions.intervals,
projectPath,
includeDetails,
warningWithFiles:
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
warningWithoutFiles: 'No file edits found within persisted workIntervals.',
});
if (intervalScoped && intervalScoped.files.length > 0) {
return intervalScoped;
}
}
return {
teamName,
taskId,
@ -163,6 +166,70 @@ export class TaskChangeComputer {
};
}
private selectScopedLogRefs(logRefs: LogFileRef[], scopes: TaskChangeScope[]): LogFileRef[] {
const scopedMembers = new Set(
scopes.map((scope) => scope.memberName).filter((memberName) => memberName.length > 0)
);
if (scopedMembers.size === 0) {
return logRefs;
}
const selected = logRefs.filter((ref) => scopedMembers.has(ref.memberName));
return selected.length > 0 ? selected : logRefs;
}
private async buildIntervalScopedTaskChangeSet(input: {
teamName: string;
taskId: string;
taskMeta: ResolvedTaskChangeComputeInput['taskMeta'];
logRefs: LogFileRef[];
intervals?: { startedAt: string; completedAt?: string }[];
projectPath?: string;
includeDetails: boolean;
warningWithFiles: string;
warningWithoutFiles: string;
}): Promise<TaskChangeSetV2 | null> {
const intervals = input.intervals;
if (!Array.isArray(intervals) || intervals.length === 0) {
return null;
}
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(
input.logRefs,
intervals,
input.projectPath,
input.includeDetails
);
return {
teamName: input.teamName,
taskId: input.taskId,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: 'medium',
computedAt: new Date().toISOString(),
scope: {
taskId: input.taskId,
memberName: input.taskMeta?.owner ?? input.logRefs[0]?.memberName ?? '',
startLine: 0,
endLine: 0,
startTimestamp,
endTimestamp,
toolUseIds,
filePaths: files.map((file) => file.filePath),
confidence: {
tier: 2,
label: 'medium',
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
},
},
warnings: [files.length === 0 ? input.warningWithoutFiles : input.warningWithFiles],
};
}
private async extractIntervalScopedChanges(
logRefs: LogFileRef[],
intervals: { startedAt: string; completedAt?: string }[],
@ -236,7 +303,7 @@ export class TaskChangeComputer {
const toolUseIdsSet = new Set<string>();
for (const { snippets } of allParsed) {
for (const snippet of snippets) {
for (const { snippet } of snippets) {
if (snippet.isError) continue;
if (!inAnyInterval(snippet.timestamp)) continue;
allowedSnippets.push(snippet);
@ -258,24 +325,29 @@ export class TaskChangeComputer {
};
}
private async extractFilteredChanges(
private async extractScopedChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>,
scopes: TaskChangeScope[],
projectPath?: string,
includeDetails = true
): Promise<FileChangeSummary[]> {
const scopesWithTools = scopes.filter((scope) => scope.toolUseIds.length > 0);
if (scopesWithTools.length === 0) {
return [];
}
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allSnippets: SnippetDiff[] = [];
for (const { snippets } of allParsed) {
if (allowedToolUseIds.size > 0) {
for (const snippet of snippets) {
if (allowedToolUseIds.has(snippet.toolUseId)) {
allSnippets.push(snippet);
}
}
} else {
allSnippets.push(...snippets);
for (let index = 0; index < allParsed.length; index++) {
const ref = logRefs[index];
const parsed = allParsed[index];
if (!ref || !parsed) continue;
const matchingScopes = this.selectScopesForLogRef(scopesWithTools, ref);
if (matchingScopes.length === 0) continue;
for (const record of parsed.snippets) {
if (this.recordMatchesAnyScope(record, matchingScopes)) allSnippets.push(record.snippet);
}
}
@ -286,6 +358,52 @@ export class TaskChangeComputer {
);
}
private selectScopesForLogRef(scopes: TaskChangeScope[], ref: LogFileRef): TaskChangeScope[] {
return scopes.filter((scope) => {
if (!scope.memberName) return true;
return scope.memberName === ref.memberName;
});
}
private recordMatchesAnyScope(record: ParsedSnippetRecord, scopes: TaskChangeScope[]): boolean {
return scopes.some((scope) => this.recordMatchesScope(record, scope));
}
private recordMatchesScope(record: ParsedSnippetRecord, scope: TaskChangeScope): boolean {
const snippet = record.snippet;
if (!scope.toolUseIds.includes(snippet.toolUseId)) return false;
if (record.sourceLine < scope.startLine || record.sourceLine > scope.endLine) return false;
if (!this.timestampMatchesScope(snippet.timestamp, scope)) return false;
if (!this.filePathMatchesScope(snippet.filePath, scope.filePaths)) return false;
return true;
}
private timestampMatchesScope(timestamp: string, scope: TaskChangeScope): boolean {
const snippetMs = Date.parse(timestamp);
if (!Number.isFinite(snippetMs)) return true;
const startMs = scope.startTimestamp ? Date.parse(scope.startTimestamp) : Number.NaN;
if (Number.isFinite(startMs) && snippetMs < startMs) return false;
const endMs = scope.endTimestamp ? Date.parse(scope.endTimestamp) : Number.NaN;
if (Number.isFinite(endMs) && snippetMs > endMs) return false;
return true;
}
private filePathMatchesScope(filePath: string, scopeFilePaths: string[]): boolean {
if (scopeFilePaths.length === 0) return true;
const normalizedFilePath = this.normalizeFilePathKey(filePath);
return scopeFilePaths.some((scopePath) => {
const normalizedScopePath = this.normalizeFilePathKey(scopePath);
return (
normalizedFilePath === normalizedScopePath ||
normalizedFilePath.endsWith(`/${normalizedScopePath}`) ||
normalizedScopePath.endsWith(`/${normalizedFilePath}`)
);
});
}
private async fallbackSingleTaskScope(
teamName: string,
taskId: string,
@ -295,7 +413,7 @@ export class TaskChangeComputer {
): Promise<TaskChangeSetV2> {
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
const allSnippets = this.sortSnippetsChronologically(
allParsed.flatMap((result) => result.snippets)
allParsed.flatMap((result) => result.snippets.map((record) => record.snippet))
);
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
@ -319,7 +437,7 @@ export class TaskChangeComputer {
filePaths: aggregatedFiles.map((file) => file.filePath),
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
},
warnings: ['No task boundaries found showing all changes from related sessions.'],
warnings: ['No task boundaries found - showing all changes from related sessions.'],
};
}
@ -348,12 +466,10 @@ export class TaskChangeComputer {
};
}
private async parseJSONLFilesWithConcurrency(
paths: string[]
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
private async parseJSONLFilesWithConcurrency(paths: string[]): Promise<ParsedSnippetsResult[]> {
if (paths.length === 0) return [];
const results = new Array<{ snippets: SnippetDiff[]; mtime: number }>(paths.length);
const results = new Array<ParsedSnippetsResult>(paths.length);
let nextIndex = 0;
const worker = async (): Promise<void> => {
@ -405,17 +521,22 @@ export class TaskChangeComputer {
filePath: string,
fileMtime: number
): Promise<ParsedSnippetsResult> {
const entries: Record<string, unknown>[] = [];
const entries: ParsedJsonlEntry[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let lineNumber = 0;
for await (const line of rl) {
lineNumber += 1;
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
entries.push({
entry: JSON.parse(trimmed) as Record<string, unknown>,
lineNumber,
});
} catch {
// Ignore invalid JSON lines.
}
@ -428,11 +549,11 @@ export class TaskChangeComputer {
return { snippets: [], mtime: 0 };
}
const erroredIds = this.collectErroredToolUseIds(entries);
const snippets: SnippetDiff[] = [];
const erroredIds = this.collectErroredToolUseIds(entries.map((record) => record.entry));
const snippets: ParsedSnippetRecord[] = [];
const seenFiles = new Set<string>();
for (const entry of entries) {
for (const { entry, lineNumber } of entries) {
const role = this.extractRole(entry);
if (role !== 'assistant') continue;
@ -459,18 +580,26 @@ export class TaskChangeComputer {
if (!input) continue;
const isError = erroredIds.has(toolUseId);
const addSnippet = (snippet: SnippetDiff): void => {
snippets.push({ snippet, sourceLine: lineNumber });
};
if (toolName === 'Edit') {
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
const replaceAll = input.replace_all === true;
const hasTextPayload =
typeof input.old_string === 'string' || typeof input.new_string === 'string';
const metadataPaths = hasTextPayload ? [] : this.extractMetadataChangePaths(input);
const targetPaths =
metadataPaths.length > 0 ? metadataPaths : targetPath ? [{ filePath: targetPath }] : [];
if (targetPath) {
seenFiles.add(this.normalizeFilePathKey(targetPath));
snippets.push({
for (const target of targetPaths) {
seenFiles.add(this.normalizeFilePathKey(target.filePath));
addSnippet({
toolUseId,
filePath: targetPath,
filePath: target.filePath,
toolName: 'Edit',
type: 'edit',
oldString,
@ -489,7 +618,7 @@ export class TaskChangeComputer {
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
const isNew = !seenFiles.has(normalizedTargetPath);
seenFiles.add(normalizedTargetPath);
snippets.push({
addSnippet({
toolUseId,
filePath: targetPath,
toolName: 'Write',
@ -513,7 +642,7 @@ export class TaskChangeComputer {
const editObj = edit as Record<string, unknown>;
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
snippets.push({
addSnippet({
toolUseId,
filePath: targetPath,
toolName: 'MultiEdit',
@ -559,6 +688,25 @@ export class TaskChangeComputer {
return null;
}
private extractMetadataChangePaths(input: Record<string, unknown>): MetadataChangePath[] {
const changes = Array.isArray(input.changes) ? input.changes : [];
const paths: MetadataChangePath[] = [];
const seen = new Set<string>();
for (const change of changes) {
if (!change || typeof change !== 'object') continue;
const changeObj = change as Record<string, unknown>;
const filePath = typeof changeObj.path === 'string' ? changeObj.path : '';
if (!filePath) continue;
const normalized = this.normalizeFilePathKey(filePath);
if (seen.has(normalized)) continue;
seen.add(normalized);
paths.push({ filePath });
}
return paths;
}
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
const erroredIds = new Set<string>();
@ -693,6 +841,7 @@ export class TaskChangeComputer {
}
case 'edit': {
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
if (snippet.oldString === '' && snippet.newString === '') return 'File change metadata';
if (snippet.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
return `Changed ${removed}${added} lines`;

View file

@ -31,6 +31,7 @@ import { FileEditTimeline } from './FileEditTimeline';
import { buildInitialReviewFileScrollKey } from './initialReviewFileScroll';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
import { buildPathChangeLabels } from './pathChangeLabels';
import { getResolvedReviewModifiedContent, isReviewRejectable } from './reviewContentPreview';
import { resolveReviewFilePath } from './reviewFilePathResolution';
import { ReviewFileTree } from './ReviewFileTree';
import { ReviewToolbar } from './ReviewToolbar';
@ -185,7 +186,7 @@ export const ChangeReviewDialog = ({
globalTasks,
} = useStore();
// Build scope keys (pure values safe to compute before hooks that depend on them)
// Build scope keys (pure values - safe to compute before hooks that depend on them)
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
// Filesystem-safe: use `-` instead of `:` for decision persistence key
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
@ -242,7 +243,7 @@ export const ChangeReviewDialog = ({
// EditorView map for all visible file editors
const editorViewMapRef = useRef(new Map<string, EditorView>());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Last focused CM editor for Cmd+Z outside editor
// Last focused CM editor - for Cmd+Z outside editor
const lastFocusedEditorRef = useRef<EditorView | null>(null);
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
const lastBulkActionAtRef = useRef<number>(0);
@ -330,6 +331,18 @@ export const ChangeReviewDialog = ({
return buildPathChangeLabels(activeChangeSet?.files ?? [], fileContents);
}, [activeChangeSet, fileContents]);
const rejectablePendingFiles = useMemo(
() =>
sortedFiles.filter((file) => {
const reviewKey = getFileReviewKey(file);
const fileDecision = fileDecisions[reviewKey] ?? fileDecisions[file.filePath] ?? 'pending';
if (fileDecision !== 'pending') return false;
return isReviewRejectable(file, fileContents[file.filePath] ?? null);
}),
[fileContents, fileDecisions, sortedFiles]
);
const canRejectAll = rejectablePendingFiles.length > 0;
const {
viewedSet,
isViewed,
@ -426,9 +439,12 @@ export const ChangeReviewDialog = ({
const handleRejectAll = useCallback(() => {
if (!activeChangeSet) return;
const rejectableFilePaths = new Set(rejectablePendingFiles.map((file) => file.filePath));
if (rejectableFilePaths.size === 0) return;
pushReviewUndoSnapshot();
lastBulkActionAtRef.current = Date.now();
for (const file of activeChangeSet.files) {
if (!rejectableFilePaths.has(file.filePath)) continue;
rejectAllFile(file.filePath);
}
requestAnimationFrame(() => {
@ -443,6 +459,7 @@ export const ChangeReviewDialog = ({
}
}, [
activeChangeSet,
rejectablePendingFiles,
rejectAllFile,
pushReviewUndoSnapshot,
applyReview,
@ -489,16 +506,8 @@ export const ChangeReviewDialog = ({
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
if (result && !hasErrorForFile && file) {
// Keep undo payload so Ctrl/Cmd+Z can restore the file (and re-add it to the review list).
const cachedModified = fileContents[filePath]?.modifiedFullContent;
const restoreContent =
cachedModified ??
(() => {
const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
);
if (writeSnippets.length === 0) return '';
return writeSnippets[writeSnippets.length - 1].newString;
})();
getResolvedReviewModifiedContent(file, fileContents[filePath] ?? null) ?? '';
const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0;
removedNewFileUndoStackRef.current.push({
file,
@ -688,7 +697,7 @@ export const ChangeReviewDialog = ({
}, SELECTION_DEBOUNCE_MS);
}, []);
// Scroll repositioning re-query coords when parent scrolls (rAF-throttled)
// Scroll repositioning - re-query coords when parent scrolls (rAF-throttled)
const hasData = !changeSetLoading && !changeSetError && !!activeChangeSet;
useEffect(() => {
if (!hasData) return;
@ -832,7 +841,7 @@ export const ChangeReviewDialog = ({
void fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {});
}
// On close clear only volatile cache, keep decisions in store
// On close - clear only volatile cache, keep decisions in store
return () => clearChangeReviewCache();
}, [
open,
@ -1110,6 +1119,7 @@ export const ChangeReviewDialog = ({
addReviewFile,
updateEditedContent,
saveEditedFile,
markRecentReviewWrite,
projectPath,
scheduleScrollToFile,
]);
@ -1277,6 +1287,7 @@ export const ChangeReviewDialog = ({
onRejectAll={handleRejectAll}
onApply={handleApply}
onCollapseUnchangedChange={setCollapseUnchanged}
canRejectAll={canRejectAll}
instantApply={REVIEW_INSTANT_APPLY}
editedCount={editedCount}
canUndo={reviewUndoStack.length > 0}

View file

@ -3,6 +3,13 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
import {
getResolvedReviewModifiedContent,
hasReviewSnippetText,
isReviewFileMissingOnDisk,
isReviewTextContentUnavailable,
shouldRenderCurrentDiskContextPreview,
} from './reviewContentPreview';
import { ReviewDiffContent } from './ReviewDiffContent';
import {
shouldRenderCodeMirrorReviewDiff,
@ -51,7 +58,8 @@ export const FileSectionDiff = ({
}: FileSectionDiffProps): React.ReactElement => {
const localEditorViewRef = useRef<EditorView | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const canRenderSnippetPreview = shouldRenderSnippetReviewPreview(file.snippets);
const hasSnippetText = hasReviewSnippetText(file);
const canRenderSnippetPreview = hasSnippetText && shouldRenderSnippetReviewPreview(file.snippets);
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
// This fires on every editor lifecycle event: initial mount, key-change remount,
@ -85,8 +93,7 @@ export const FileSectionDiff = ({
// Loading state
if (isLoading) {
const hasSnippetPreview = file.snippets.some((snippet) => !snippet.isError);
if (!hasSnippetPreview) {
if (!hasSnippetText) {
return <FileSectionPlaceholder fileName={file.relativePath} />;
}
@ -103,21 +110,12 @@ export const FileSectionDiff = ({
}
// Resolve modified content: prefer full content, fall back to write-type snippet
// Only write-new/write-update snippets contain the full file — edit snippets are partial
const resolvedModified =
fileContent?.modifiedFullContent ??
(() => {
const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
);
if (writeSnippets.length === 0) return null;
// Take the last write (most recent full-file content)
return writeSnippets[writeSnippets.length - 1].newString;
})();
// Only write-new/write-update snippets contain the full file - edit snippets are partial
const resolvedModified = getResolvedReviewModifiedContent(file, fileContent);
const resolvedOriginal = fileContent?.originalFullContent ?? null;
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
const hasLedgerManualAction = file.snippets.some(
(snippet) =>
!!snippet.ledger &&
@ -138,22 +136,64 @@ export const FileSectionDiff = ({
const canRenderCodeMirrorSafely =
canRenderCodeMirror &&
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
const canRenderCurrentDiskContext =
resolvedModified !== null &&
shouldRenderCurrentDiskContextPreview(file, fileContent) &&
shouldRenderCodeMirrorReviewDiff(resolvedModified, resolvedModified);
const currentDiskContextContent = canRenderCurrentDiskContext ? resolvedModified : null;
if (!canRenderCodeMirrorSafely) {
return (
<div className="overflow-auto">
<OversizedDiffNotice
message={
hasLedgerManualAction || isContentUnavailable
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
: canRenderCodeMirror && !canRenderSnippetPreview
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
: canRenderCodeMirror
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
: 'Diff preview skipped because the available change data is too large to render safely.'
canRenderCurrentDiskContext
? 'No original baseline is available; showing current disk content for context only. Reject is disabled for this file.'
: hasLedgerManualAction || isContentUnavailable
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
: canRenderCodeMirror && !canRenderSnippetPreview
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
: canRenderCodeMirror
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
: hasSnippetText
? 'Diff preview skipped because the available change data is too large to render safely.'
: file.snippets.length > 0
? 'This file change was captured as metadata only; no text diff data is available.'
: 'No text diff data is available for this file.'
}
/>
{canRenderSnippetPreview ? <ReviewDiffContent file={file} /> : null}
{canRenderSnippetPreview ? (
<ReviewDiffContent file={file} />
) : currentDiskContextContent != null ? (
<DiffErrorBoundary
filePath={file.filePath}
oldString={currentDiskContextContent}
newString={currentDiskContextContent}
>
<CodeMirrorDiffView
key={`${file.filePath}:${discardCounter}:current-disk-context`}
original={currentDiskContextContent}
modified={currentDiskContextContent}
fileName={file.relativePath}
readOnly={true}
showMergeControls={false}
collapseUnchanged={false}
usePortionCollapse={true}
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
onHunkRejected={(idx) => onHunkRejected(file.filePath, idx)}
onContentChanged={(content) => onContentChanged(file.filePath, content)}
editorViewRef={localEditorViewRef}
onViewChange={handleViewChange}
onSelectionChange={
onSelectionChange
? (info) => onSelectionChange(info ? { ...info, filePath: file.filePath } : null)
: undefined
}
globalHunkOffset={globalHunkOffset}
totalReviewHunks={totalReviewHunks}
/>
</DiffErrorBoundary>
) : null}
<div ref={sentinelRef} className="h-1 shrink-0" />
</div>
);

View file

@ -5,6 +5,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { ChevronDown, ChevronRight, FilePlus, GitBranch, Loader2, Save, Undo2 } from 'lucide-react';
import {
getResolvedReviewModifiedContent,
getReviewRejectBlockReason,
isReviewFileMissingOnDisk,
isReviewTextContentUnavailable,
requiresManualLedgerReview,
} from './reviewContentPreview';
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
import type { FileChangeSummary } from '@shared/types/review';
@ -57,32 +65,15 @@ export const FileSectionHeader = ({
onAcceptFile,
onRejectFile,
}: FileSectionHeaderProps): React.ReactElement => {
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
const restoreContent = getResolvedReviewModifiedContent(file, fileContent);
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
const isPreviewOnly = isMissingOnDisk || isContentUnavailable;
const requiresManualLedgerReview = file.snippets.some(
(snippet) =>
!!snippet.ledger &&
(!!snippet.ledger.beforeState?.unavailableReason ||
!!snippet.ledger.afterState?.unavailableReason) &&
(snippet.ledger.originalFullContent == null || snippet.ledger.modifiedFullContent == null)
);
const rejectDisabled = isPreviewOnly || requiresManualLedgerReview;
const restoreContent =
fileContent?.modifiedFullContent ??
(() => {
const writeSnippets = file.snippets.filter(
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
);
if (writeSnippets.length === 0) return null;
return writeSnippets[writeSnippets.length - 1].newString;
})();
const manualLedgerReviewRequired = requiresManualLedgerReview(file);
const rejectBlockReason = getReviewRejectBlockReason(file, fileContent);
const rejectDisabled = rejectBlockReason !== null;
const canRestore =
!!onRestoreMissingFile &&
isMissingOnDisk &&
!isContentUnavailable &&
!hasEdits &&
restoreContent != null;
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
const externalChangeLabel =
externalChange?.type === 'unlink'
? 'Deleted on disk'
@ -240,7 +231,7 @@ export const FileSectionHeader = ({
</span>
)}
{requiresManualLedgerReview && (
{manualLedgerReviewRequired && (
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
MANUAL REVIEW
</span>
@ -315,11 +306,13 @@ export const FileSectionHeader = ({
</TooltipTrigger>
{rejectDisabled && (
<TooltipContent side="bottom">
{requiresManualLedgerReview
{rejectBlockReason === 'manual-ledger-review'
? 'Reject is disabled because this ledger change has binary, large, or unavailable content.'
: isContentUnavailable
: rejectBlockReason === 'content-unavailable'
? 'Reject is disabled because full text content is unavailable.'
: 'Accept/Reject is disabled while the file is missing on disk.'}
: rejectBlockReason === 'missing-on-disk'
? 'Accept/Reject is disabled while the file is missing on disk.'
: 'Reject is disabled because the original baseline is unavailable.'}
</TooltipContent>
)}
</Tooltip>

View file

@ -18,6 +18,7 @@ interface ReviewToolbarProps {
onRejectAll: () => void;
onApply: () => void;
onCollapseUnchangedChange: (collapse: boolean) => void;
canRejectAll?: boolean;
editedCount?: number;
canUndo?: boolean;
onUndo?: () => void;
@ -34,6 +35,7 @@ export const ReviewToolbar = ({
onRejectAll,
onApply,
onCollapseUnchangedChange: _onCollapseUnchangedChange,
canRejectAll = true,
instantApply = false,
editedCount = 0,
canUndo = false,
@ -43,6 +45,7 @@ export const ReviewToolbar = ({
const canApply = hasRejected && !applying;
const totalChanges = stats.pending + stats.accepted + stats.rejected;
const reviewedCount = stats.accepted + stats.rejected;
const rejectAllDisabled = applying || !canRejectAll;
return (
<div className="flex items-center gap-3 border-b border-border bg-surface-sidebar px-4 py-2">
@ -175,15 +178,27 @@ export const ReviewToolbar = ({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRejectAll}
className="flex items-center gap-1 rounded bg-red-500/15 px-2.5 py-1 text-xs text-red-400 transition-colors hover:bg-red-500/25"
>
<X className="size-3" />
Reject All
</button>
<span>
<button
onClick={onRejectAll}
disabled={rejectAllDisabled}
className={cn(
'flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50',
rejectAllDisabled
? 'bg-red-500/10 text-red-500'
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25'
)}
>
<X className="size-3" />
Reject All
</button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
<TooltipContent side="bottom">
{canRejectAll
? 'Reject all safely rejectable changes across all files'
: 'No pending files have a safe original baseline to reject.'}
</TooltipContent>
</Tooltip>
</>
)}

View file

@ -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<FileChangeSummary, 'snippets'>): boolean {
return file.snippets.some(
(snippet) => !snippet.isError && (snippet.oldString.length > 0 || snippet.newString.length > 0)
);
}
export function getLastWriteSnippetContent(
file: Pick<FileChangeSummary, 'snippets'>
): 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<FileChangeSummary, 'snippets'>,
fileContent: Pick<FileChangeWithContent, 'modifiedFullContent'> | null
): string | null {
return fileContent?.modifiedFullContent ?? getLastWriteSnippetContent(file);
}
export function isReviewFileMissingOnDisk(
fileContent: Pick<FileChangeWithContent, 'modifiedFullContent'> | null
): boolean {
return fileContent ? fileContent.modifiedFullContent == null : false;
}
export function isReviewTextContentUnavailable(
file: Pick<FileChangeSummary, 'snippets'>,
fileContent: Pick<FileChangeWithContent, 'contentSource' | 'modifiedFullContent'> | null
): boolean {
return (
fileContent?.contentSource === 'unavailable' &&
getResolvedReviewModifiedContent(file, fileContent) === null
);
}
export function requiresManualLedgerReview(file: Pick<FileChangeSummary, 'snippets'>): 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<FileChangeSummary, 'snippets' | 'isNewFile'>,
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<FileChangeSummary, 'snippets' | 'isNewFile'>,
fileContent: ReviewContentAvailability | null
): boolean {
return getReviewRejectBlockReason(file, fileContent) === null;
}
export function shouldRenderCurrentDiskContextPreview(
file: Pick<FileChangeSummary, 'snippets' | 'isNewFile'>,
fileContent: ReviewContentAvailability | null
): boolean {
return (
fileContent?.contentSource === 'disk-current' &&
fileContent.modifiedFullContent != null &&
getReviewRejectBlockReason(file, fileContent) === 'baseline-unavailable'
);
}

View file

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

View file

@ -14,9 +14,14 @@ async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
);
}
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']);
});
});

View file

@ -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> = {}): FileChangeSummary {
return {
filePath: '/repo/calc112/calc.js',
relativePath: 'calc112/calc.js',
snippets: [],
linesAdded: 0,
linesRemoved: 0,
isNewFile: true,
...overrides,
};
}
function makeContent(overrides: Partial<FileChangeWithContent> = {}): 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);
});
});

View file

@ -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(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[node]}
getLogWorldRect={() => ({
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<HTMLDivElement>('.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',

View file

@ -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<HTMLButtonElement>;
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<void> {
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(
<GraphTaskCard
node={taskNode}
teamName="northstar-core"
onClose={onClose}
onStartTask={noop}
onCompleteTask={noop}
onApproveTask={noop}
onRequestReview={noop}
onRequestChanges={noop}
onCancelTask={noop}
onMoveBackToDone={noop}
onViewChanges={onViewChanges}
/>
);
await flushReact();
});
const changesButton = host.querySelector<HTMLButtonElement>('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();
});
});
});