fix: harden task change review flows
This commit is contained in:
parent
240bc81d0a
commit
e333d09d9c
23 changed files with 2150 additions and 322 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
246
src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx
Normal file
246
src/features/agent-graph/renderer/hooks/useGraphTaskActions.tsx
Normal 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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
99
src/renderer/components/team/review/reviewContentPreview.ts
Normal file
99
src/renderer/components/team/review/reviewContentPreview.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
167
test/renderer/features/agent-graph/GraphTaskCard.test.tsx
Normal file
167
test/renderer/features/agent-graph/GraphTaskCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue