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_TITLE_LIMIT = 24;
|
||||||
const COMPACT_ROW_TEXT_LIMIT = 76;
|
const COMPACT_ROW_TEXT_LIMIT = 76;
|
||||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
||||||
|
const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto';
|
||||||
|
|
||||||
interface StableRectLike {
|
interface StableRectLike {
|
||||||
left: number;
|
left: number;
|
||||||
|
|
@ -427,7 +428,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
|
|
||||||
const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
const baseOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
||||||
shell.style.opacity = String(baseOpacity);
|
shell.style.opacity = String(baseOpacity);
|
||||||
shell.style.pointerEvents = 'auto';
|
shell.style.pointerEvents = 'none';
|
||||||
shell.style.left = `${Math.round(laneRect.left)}px`;
|
shell.style.left = `${Math.round(laneRect.left)}px`;
|
||||||
shell.style.top = `${Math.round(laneRect.top)}px`;
|
shell.style.top = `${Math.round(laneRect.top)}px`;
|
||||||
shell.style.width = `${Math.round(laneRect.width)}px`;
|
shell.style.width = `${Math.round(laneRect.width)}px`;
|
||||||
|
|
@ -544,7 +545,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={[
|
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,
|
rowStateClassName,
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
title={titleText}
|
title={titleText}
|
||||||
|
|
@ -593,7 +594,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
shellRefs.current.set(node.id, 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={{
|
style={{
|
||||||
width: `${laneWidth}px`,
|
width: `${laneWidth}px`,
|
||||||
maxWidth: `${laneWidth}px`,
|
maxWidth: `${laneWidth}px`,
|
||||||
|
|
@ -614,7 +615,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
) : isEmptyLoading ? (
|
) : isEmptyLoading ? (
|
||||||
<button
|
<button
|
||||||
type="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-busy="true"
|
||||||
aria-label="Loading logs"
|
aria-label="Loading logs"
|
||||||
onClick={() => openLogs(memberName)}
|
onClick={() => openLogs(memberName)}
|
||||||
|
|
@ -625,7 +626,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => openLogs(memberName)}
|
||||||
>
|
>
|
||||||
{resolveEmptyText(preview, loading, error)}
|
{resolveEmptyText(preview, loading, error)}
|
||||||
|
|
@ -634,7 +635,7 @@ export const GraphMemberLogPreviewHud = ({
|
||||||
{preview && preview.overflowCount > 0 ? (
|
{preview && preview.overflowCount > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
onClick={() => openLogs(memberName)}
|
||||||
>
|
>
|
||||||
+{preview.overflowCount} more
|
+{preview.overflowCount} more
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ import { useGraphMemberPopoverContext } from '../hooks/useGraphMemberPopoverCont
|
||||||
import { GraphTaskCard } from './GraphTaskCard';
|
import { GraphTaskCard } from './GraphTaskCard';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
import type {
|
||||||
|
MemberActivityFilter,
|
||||||
|
MemberDetailTab,
|
||||||
|
} from '@renderer/components/team/members/memberDetailTypes';
|
||||||
import type { TeamTaskWithKanban } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
||||||
|
|
@ -74,7 +78,13 @@ interface GraphNodePopoverProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage?: (memberName: string) => void;
|
onSendMessage?: (memberName: string) => void;
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
onOpenMemberProfile?: (memberName: string) => void;
|
onOpenMemberProfile?: (
|
||||||
|
memberName: string,
|
||||||
|
options?: {
|
||||||
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
|
initialTab?: MemberDetailTab;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
onCreateTask?: (owner: string) => void;
|
onCreateTask?: (owner: string) => void;
|
||||||
onStartTask?: (taskId: string) => void;
|
onStartTask?: (taskId: string) => void;
|
||||||
onCompleteTask?: (taskId: string) => void;
|
onCompleteTask?: (taskId: string) => void;
|
||||||
|
|
@ -83,6 +93,7 @@ interface GraphNodePopoverProps {
|
||||||
onRequestChanges?: (taskId: string) => void;
|
onRequestChanges?: (taskId: string) => void;
|
||||||
onCancelTask?: (taskId: string) => void;
|
onCancelTask?: (taskId: string) => void;
|
||||||
onMoveBackToDone?: (taskId: string) => void;
|
onMoveBackToDone?: (taskId: string) => void;
|
||||||
|
onViewChanges?: (taskId: string) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,6 +112,7 @@ export const GraphNodePopover = ({
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
onCancelTask,
|
onCancelTask,
|
||||||
onMoveBackToDone,
|
onMoveBackToDone,
|
||||||
|
onViewChanges,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: GraphNodePopoverProps): React.JSX.Element => {
|
}: GraphNodePopoverProps): React.JSX.Element => {
|
||||||
if (node.kind === 'member' || node.kind === 'lead') {
|
if (node.kind === 'member' || node.kind === 'lead') {
|
||||||
|
|
@ -140,6 +152,7 @@ export const GraphNodePopover = ({
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
onCancelTask={onCancelTask}
|
onCancelTask={onCancelTask}
|
||||||
onMoveBackToDone={onMoveBackToDone}
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
|
onViewChanges={onViewChanges}
|
||||||
onDeleteTask={onDeleteTask}
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface GraphTaskCardProps {
|
||||||
onRequestChanges?: (taskId: string) => void;
|
onRequestChanges?: (taskId: string) => void;
|
||||||
onCancelTask?: (taskId: string) => void;
|
onCancelTask?: (taskId: string) => void;
|
||||||
onMoveBackToDone?: (taskId: string) => void;
|
onMoveBackToDone?: (taskId: string) => void;
|
||||||
|
onViewChanges?: (taskId: string) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +81,7 @@ export const GraphTaskCard = ({
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
onCancelTask,
|
onCancelTask,
|
||||||
onMoveBackToDone,
|
onMoveBackToDone,
|
||||||
|
onViewChanges,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
}: GraphTaskCardProps): React.JSX.Element => {
|
}: GraphTaskCardProps): React.JSX.Element => {
|
||||||
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : '';
|
||||||
|
|
@ -143,6 +145,7 @@ export const GraphTaskCard = ({
|
||||||
onRequestChanges={closeAct(onRequestChanges)}
|
onRequestChanges={closeAct(onRequestChanges)}
|
||||||
onCancelTask={closeAct(onCancelTask)}
|
onCancelTask={closeAct(onCancelTask)}
|
||||||
onMoveBackToDone={closeAct(onMoveBackToDone)}
|
onMoveBackToDone={closeAct(onMoveBackToDone)}
|
||||||
|
onViewChanges={onViewChanges ? closeAct(onViewChanges) : undefined}
|
||||||
onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined}
|
onDeleteTask={onDeleteTask ? closeAct(onDeleteTask) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
* Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50).
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
|
||||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
|
|
@ -26,10 +26,6 @@ import type {
|
||||||
GraphEventPort,
|
GraphEventPort,
|
||||||
TransientHandoffCard,
|
TransientHandoffCard,
|
||||||
} from '@claude-teams/agent-graph';
|
} from '@claude-teams/agent-graph';
|
||||||
import type {
|
|
||||||
MemberActivityFilter,
|
|
||||||
MemberDetailTab,
|
|
||||||
} from '@renderer/components/team/members/memberDetailTypes';
|
|
||||||
|
|
||||||
export interface TeamGraphOverlayProps {
|
export interface TeamGraphOverlayProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -38,15 +34,6 @@ export interface TeamGraphOverlayProps {
|
||||||
sidebarVisible?: boolean;
|
sidebarVisible?: boolean;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
messagesPanelEnabled?: boolean;
|
messagesPanelEnabled?: boolean;
|
||||||
onSendMessage?: (memberName: string) => void;
|
|
||||||
onOpenTaskDetail?: (taskId: string) => void;
|
|
||||||
onOpenMemberProfile?: (
|
|
||||||
memberName: string,
|
|
||||||
options?: {
|
|
||||||
initialTab?: MemberDetailTab;
|
|
||||||
initialActivityFilter?: MemberActivityFilter;
|
|
||||||
}
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamGraphOverlay = ({
|
export const TeamGraphOverlay = ({
|
||||||
|
|
@ -56,9 +43,6 @@ export const TeamGraphOverlay = ({
|
||||||
sidebarVisible,
|
sidebarVisible,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
messagesPanelEnabled = true,
|
messagesPanelEnabled = true,
|
||||||
onSendMessage,
|
|
||||||
onOpenTaskDetail,
|
|
||||||
onOpenMemberProfile,
|
|
||||||
}: TeamGraphOverlayProps): React.JSX.Element => {
|
}: TeamGraphOverlayProps): React.JSX.Element => {
|
||||||
const graphData = useTeamGraphAdapter(teamName);
|
const graphData = useTeamGraphAdapter(teamName);
|
||||||
const {
|
const {
|
||||||
|
|
@ -69,7 +53,7 @@ export const TeamGraphOverlay = ({
|
||||||
} = useTeamGraphSurfaceActions(teamName);
|
} = useTeamGraphSurfaceActions(teamName);
|
||||||
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
const { sidebarVisible: persistedSidebarVisible, toggleSidebarVisible } =
|
||||||
useGraphSidebarVisibility();
|
useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const interactions = useGraphSurfaceInteractions(teamName);
|
||||||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
@ -79,55 +63,29 @@ export const TeamGraphOverlay = ({
|
||||||
teamName,
|
teamName,
|
||||||
enabled: messagesPanelEnabled,
|
enabled: messagesPanelEnabled,
|
||||||
mountPoint: messagesPanelMountPoint,
|
mountPoint: messagesPanelMountPoint,
|
||||||
onOpenMemberProfile: (memberName) => onOpenMemberProfile?.(memberName),
|
onOpenMemberProfile: interactions.openMemberProfile,
|
||||||
onOpenTaskDetail: (taskId) => onOpenTaskDetail?.(taskId),
|
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(() => {
|
const openTeamPage = useCallback(() => {
|
||||||
openTeamTab();
|
openTeamTab();
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose, openTeamTab]);
|
}, [onClose, openTeamTab]);
|
||||||
const openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
interactions.openCreateTask('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [interactions]);
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
if (ref.kind === 'task') onOpenTaskDetail?.(ref.taskId);
|
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
|
||||||
else if (ref.kind === 'member') onOpenMemberProfile?.(ref.memberName);
|
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
|
||||||
},
|
},
|
||||||
[onOpenTaskDetail, onOpenMemberProfile]
|
[interactions]
|
||||||
),
|
|
||||||
onSendMessage: useCallback(
|
|
||||||
(memberName: string) => onSendMessage?.(memberName),
|
|
||||||
[onSendMessage]
|
|
||||||
),
|
|
||||||
onOpenTaskDetail: useCallback(
|
|
||||||
(taskId: string) => onOpenTaskDetail?.(taskId),
|
|
||||||
[onOpenTaskDetail]
|
|
||||||
),
|
),
|
||||||
|
onSendMessage: interactions.openSendMessage,
|
||||||
|
onOpenTaskDetail: interactions.openTaskDetail,
|
||||||
onOpenMemberProfile: useCallback(
|
onOpenMemberProfile: useCallback(
|
||||||
(memberName: string) => onOpenMemberProfile?.(memberName),
|
(memberName: string) => interactions.openMemberProfile(memberName),
|
||||||
[onOpenMemberProfile]
|
[interactions]
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -205,8 +163,8 @@ export const TeamGraphOverlay = ({
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
enabled={filters?.showActivity ?? true}
|
enabled={filters?.showActivity ?? true}
|
||||||
onOpenTaskDetail={onOpenTaskDetail}
|
onOpenTaskDetail={interactions.openTaskDetail}
|
||||||
onOpenMemberProfile={onOpenMemberProfile}
|
onOpenMemberProfile={interactions.openMemberProfile}
|
||||||
/>
|
/>
|
||||||
<GraphMemberLogPreviewHud
|
<GraphMemberLogPreviewHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -217,7 +175,7 @@ export const TeamGraphOverlay = ({
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
enabled={filters?.showLogs ?? true}
|
enabled={filters?.showLogs ?? true}
|
||||||
onOpenMemberProfile={onOpenMemberProfile}
|
onOpenMemberProfile={interactions.openMemberProfile}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -230,7 +188,7 @@ export const TeamGraphOverlay = ({
|
||||||
targetNode={targetNode}
|
targetNode={targetNode}
|
||||||
onClose={closeEdge}
|
onClose={closeEdge}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
onOpenTaskDetail={onOpenTaskDetail}
|
onOpenTaskDetail={interactions.openTaskDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderOverlay={({ node, onClose: closePopover }) => (
|
renderOverlay={({ node, onClose: closePopover }) => (
|
||||||
|
|
@ -239,19 +197,27 @@ export const TeamGraphOverlay = ({
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
onClose={closePopover}
|
onClose={closePopover}
|
||||||
onSendMessage={(name) => {
|
onSendMessage={(name) => {
|
||||||
onSendMessage?.(name);
|
interactions.openSendMessage(name);
|
||||||
closePopover();
|
closePopover();
|
||||||
}}
|
}}
|
||||||
onCreateTask={openCreateTaskDialog}
|
onCreateTask={interactions.openCreateTask}
|
||||||
onOpenTaskDetail={(id) => {
|
onOpenTaskDetail={(id) => {
|
||||||
onOpenTaskDetail?.(id);
|
interactions.openTaskDetail(id);
|
||||||
closePopover();
|
closePopover();
|
||||||
}}
|
}}
|
||||||
onOpenMemberProfile={(name) => {
|
onOpenMemberProfile={(name, options) => {
|
||||||
onOpenMemberProfile?.(name);
|
interactions.openMemberProfile(name, options);
|
||||||
closePopover();
|
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}
|
) : null}
|
||||||
{graphMessagesPanel}
|
{graphMessagesPanel}
|
||||||
{createTaskDialog}
|
{interactions.dialogs}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
* Provides Fullscreen button that opens the overlay.
|
* 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 { GraphView } from '@claude-teams/agent-graph';
|
||||||
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from '@renderer/components/team/sidebar/TeamSidebarHost';
|
||||||
|
|
||||||
import { useGraphCreateTaskDialog } from '../hooks/useGraphCreateTaskDialog';
|
|
||||||
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
import { useGraphMessagesPanel } from '../hooks/useGraphMessagesPanel';
|
||||||
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
import { useGraphSidebarVisibility } from '../hooks/useGraphSidebarVisibility';
|
||||||
|
import { useGraphSurfaceInteractions } from '../hooks/useGraphSurfaceInteractions';
|
||||||
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
import { useTeamGraphAdapter } from '../hooks/useTeamGraphAdapter';
|
||||||
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
import { useTeamGraphSurfaceActions } from '../hooks/useTeamGraphSurfaceActions';
|
||||||
|
|
||||||
|
|
@ -26,10 +26,6 @@ import type {
|
||||||
GraphEventPort,
|
GraphEventPort,
|
||||||
TransientHandoffCard,
|
TransientHandoffCard,
|
||||||
} from '@claude-teams/agent-graph';
|
} from '@claude-teams/agent-graph';
|
||||||
import type {
|
|
||||||
MemberActivityFilter,
|
|
||||||
MemberDetailTab,
|
|
||||||
} from '@renderer/components/team/members/memberDetailTypes';
|
|
||||||
|
|
||||||
const TeamGraphOverlay = lazy(() =>
|
const TeamGraphOverlay = lazy(() =>
|
||||||
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
|
import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay }))
|
||||||
|
|
@ -41,11 +37,6 @@ export interface TeamGraphTabProps {
|
||||||
isPaneFocused?: boolean;
|
isPaneFocused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenProfileOptions {
|
|
||||||
initialTab?: MemberDetailTab;
|
|
||||||
initialActivityFilter?: MemberActivityFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TeamGraphTab = ({
|
export const TeamGraphTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
|
|
@ -59,86 +50,34 @@ export const TeamGraphTab = ({
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
const { sidebarVisible, toggleSidebarVisible } = useGraphSidebarVisibility();
|
||||||
const { dialog: createTaskDialog, openCreateTaskDialog } = useGraphCreateTaskDialog(teamName);
|
const interactions = useGraphSurfaceInteractions(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 openCreateTask = useCallback(() => {
|
const openCreateTask = useCallback(() => {
|
||||||
openCreateTaskDialog('');
|
interactions.openCreateTask('');
|
||||||
}, [openCreateTaskDialog]);
|
}, [interactions]);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const events: GraphEventPort = {
|
const events: GraphEventPort = {
|
||||||
onNodeDoubleClick: useCallback(
|
onNodeDoubleClick: useCallback(
|
||||||
(ref: GraphDomainRef) => {
|
(ref: GraphDomainRef) => {
|
||||||
if (ref.kind === 'task') dispatchOpenTask(ref.taskId);
|
if (ref.kind === 'task') interactions.openTaskDetail(ref.taskId);
|
||||||
else if (ref.kind === 'member') dispatchOpenProfile(ref.memberName);
|
else if (ref.kind === 'member') interactions.openMemberProfile(ref.memberName);
|
||||||
},
|
},
|
||||||
[dispatchOpenTask, dispatchOpenProfile]
|
[interactions]
|
||||||
),
|
),
|
||||||
onSendMessage: dispatchSendMessage,
|
onSendMessage: interactions.openSendMessage,
|
||||||
onOpenTaskDetail: dispatchOpenTask,
|
onOpenTaskDetail: interactions.openTaskDetail,
|
||||||
onOpenMemberProfile: useCallback(
|
onOpenMemberProfile: useCallback(
|
||||||
(memberName: string) => {
|
(memberName: string) => {
|
||||||
dispatchOpenProfile(memberName);
|
interactions.openMemberProfile(memberName);
|
||||||
},
|
},
|
||||||
[dispatchOpenProfile]
|
[interactions]
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const graphMessagesPanel = useGraphMessagesPanel({
|
const graphMessagesPanel = useGraphMessagesPanel({
|
||||||
teamName,
|
teamName,
|
||||||
enabled: isActive && isPaneFocused && !fullscreen,
|
enabled: isActive && isPaneFocused && !fullscreen,
|
||||||
mountPoint: messagesPanelMountPoint,
|
mountPoint: messagesPanelMountPoint,
|
||||||
onOpenMemberProfile: dispatchOpenProfile,
|
onOpenMemberProfile: interactions.openMemberProfile,
|
||||||
onOpenTaskDetail: dispatchOpenTask,
|
onOpenTaskDetail: interactions.openTaskDetail,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -224,8 +163,8 @@ export const TeamGraphTab = ({
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
enabled={isActive && (filters?.showActivity ?? true)}
|
enabled={isActive && (filters?.showActivity ?? true)}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={interactions.openTaskDetail}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={interactions.openMemberProfile}
|
||||||
/>
|
/>
|
||||||
<GraphMemberLogPreviewHud
|
<GraphMemberLogPreviewHud
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -236,7 +175,7 @@ export const TeamGraphTab = ({
|
||||||
getViewportSize={getViewportSize}
|
getViewportSize={getViewportSize}
|
||||||
focusNodeIds={focusNodeIds}
|
focusNodeIds={focusNodeIds}
|
||||||
enabled={isActive && (filters?.showLogs ?? true)}
|
enabled={isActive && (filters?.showLogs ?? true)}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={interactions.openMemberProfile}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -249,7 +188,7 @@ export const TeamGraphTab = ({
|
||||||
targetNode={targetNode}
|
targetNode={targetNode}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={interactions.openTaskDetail}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
renderOverlay={({ node, onClose }) => (
|
renderOverlay={({ node, onClose }) => (
|
||||||
|
|
@ -257,18 +196,19 @@ export const TeamGraphTab = ({
|
||||||
node={node}
|
node={node}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSendMessage={dispatchSendMessage}
|
onSendMessage={interactions.openSendMessage}
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
onOpenTaskDetail={interactions.openTaskDetail}
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
onOpenMemberProfile={interactions.openMemberProfile}
|
||||||
onCreateTask={openCreateTaskDialog}
|
onCreateTask={interactions.openCreateTask}
|
||||||
onStartTask={dispatchStartTask}
|
onStartTask={interactions.onStartTask}
|
||||||
onCompleteTask={dispatchCompleteTask}
|
onCompleteTask={interactions.onCompleteTask}
|
||||||
onApproveTask={dispatchApproveTask}
|
onApproveTask={interactions.onApproveTask}
|
||||||
onRequestReview={dispatchRequestReview}
|
onRequestReview={interactions.onRequestReview}
|
||||||
onRequestChanges={dispatchRequestChanges}
|
onRequestChanges={interactions.onRequestChanges}
|
||||||
onCancelTask={dispatchCancelTask}
|
onCancelTask={interactions.onCancelTask}
|
||||||
onMoveBackToDone={dispatchMoveBackToDone}
|
onMoveBackToDone={interactions.onMoveBackToDone}
|
||||||
onDeleteTask={dispatchDeleteTask}
|
onViewChanges={interactions.openTaskChanges}
|
||||||
|
onDeleteTask={interactions.onDeleteTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -280,7 +220,7 @@ export const TeamGraphTab = ({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{graphMessagesPanel}
|
{graphMessagesPanel}
|
||||||
{createTaskDialog}
|
{interactions.dialogs}
|
||||||
{fullscreen && (
|
{fullscreen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<TeamGraphOverlay
|
<TeamGraphOverlay
|
||||||
|
|
@ -288,9 +228,6 @@ export const TeamGraphTab = ({
|
||||||
onClose={() => setFullscreen(false)}
|
onClose={() => setFullscreen(false)}
|
||||||
sidebarVisible={sidebarVisible}
|
sidebarVisible={sidebarVisible}
|
||||||
onToggleSidebar={toggleSidebarVisible}
|
onToggleSidebar={toggleSidebarVisible}
|
||||||
onSendMessage={dispatchSendMessage}
|
|
||||||
onOpenTaskDetail={dispatchOpenTask}
|
|
||||||
onOpenMemberProfile={dispatchOpenProfile}
|
|
||||||
messagesPanelEnabled={isActive && isPaneFocused}
|
messagesPanelEnabled={isActive && isPaneFocused}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface BoundaryCacheEntry {
|
||||||
interface ToolUseInfo {
|
interface ToolUseInfo {
|
||||||
toolUseId: string;
|
toolUseId: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
filePath?: string;
|
filePaths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
||||||
|
|
@ -126,9 +126,9 @@ export class TaskBoundaryParser {
|
||||||
const toolName = canonicalizeAgentTeamsToolName(rawName);
|
const toolName = canonicalizeAgentTeamsToolName(rawName);
|
||||||
const toolUseId = typeof b.id === 'string' ? b.id : '';
|
const toolUseId = typeof b.id === 'string' ? b.id : '';
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
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, []);
|
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.
|
// Prefer structured task markers for modern runtime sessions.
|
||||||
|
|
@ -197,6 +197,28 @@ export class TaskBoundaryParser {
|
||||||
return null;
|
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 блоки.
|
* Найти TaskUpdate/proxy_TaskUpdate tool_use блоки.
|
||||||
* status: in_progress → start, completed → complete
|
* status: in_progress → start, completed → complete
|
||||||
|
|
@ -386,7 +408,9 @@ export class TaskBoundaryParser {
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (FILE_MODIFYING_TOOLS.has(tool.toolName) && tool.toolUseId) {
|
if (FILE_MODIFYING_TOOLS.has(tool.toolName) && tool.toolUseId) {
|
||||||
toolUseIds.push(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');
|
const logger = createLogger('Service:TaskChangeComputer');
|
||||||
|
|
||||||
interface ParsedSnippetsCacheEntry {
|
interface ParsedSnippetsCacheEntry {
|
||||||
data: SnippetDiff[];
|
data: ParsedSnippetRecord[];
|
||||||
mtime: number;
|
mtime: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedSnippetsResult {
|
interface ParsedSnippetsResult {
|
||||||
snippets: SnippetDiff[];
|
snippets: ParsedSnippetRecord[];
|
||||||
mtime: number;
|
mtime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedSnippetRecord {
|
||||||
|
snippet: SnippetDiff;
|
||||||
|
sourceLine: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface LogFileRef {
|
interface LogFileRef {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MetadataChangePath {
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedJsonlEntry {
|
||||||
|
entry: Record<string, unknown>;
|
||||||
|
lineNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class TaskChangeComputer {
|
export class TaskChangeComputer {
|
||||||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||||||
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
|
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
|
||||||
|
|
@ -60,7 +74,7 @@ export class TaskChangeComputer {
|
||||||
const merged: SnippetDiff[] = [];
|
const merged: SnippetDiff[] = [];
|
||||||
|
|
||||||
for (const result of parseResults) {
|
for (const result of parseResults) {
|
||||||
merged.push(...result.snippets);
|
merged.push(...result.snippets.map((record) => record.snippet));
|
||||||
if (result.mtime > latestMtime) {
|
if (result.mtime > latestMtime) {
|
||||||
latestMtime = result.mtime;
|
latestMtime = result.mtime;
|
||||||
}
|
}
|
||||||
|
|
@ -101,54 +115,43 @@ export class TaskChangeComputer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allScopes.length === 0) {
|
if (allScopes.length === 0) {
|
||||||
const intervals = effectiveOptions.intervals;
|
const intervalScoped = await this.buildIntervalScopedTaskChangeSet({
|
||||||
if (Array.isArray(intervals) && intervals.length > 0) {
|
teamName,
|
||||||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
taskId,
|
||||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
taskMeta,
|
||||||
|
logRefs,
|
||||||
return {
|
intervals: effectiveOptions.intervals,
|
||||||
teamName,
|
projectPath,
|
||||||
taskId,
|
includeDetails,
|
||||||
files,
|
warningWithFiles: 'Task boundaries missing - scoped by workIntervals timestamps.',
|
||||||
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
|
warningWithoutFiles: 'No file edits found within persisted workIntervals.',
|
||||||
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
|
});
|
||||||
totalFiles: files.length,
|
if (intervalScoped) return intervalScoped;
|
||||||
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.'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
|
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds));
|
const files = await this.extractScopedChanges(logRefs, allScopes, projectPath, includeDetails);
|
||||||
const files = await this.extractFilteredChanges(
|
|
||||||
logRefs,
|
|
||||||
allowedToolUseIds,
|
|
||||||
projectPath,
|
|
||||||
includeDetails
|
|
||||||
);
|
|
||||||
|
|
||||||
const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier));
|
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 {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
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(
|
private async extractIntervalScopedChanges(
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
intervals: { startedAt: string; completedAt?: string }[],
|
intervals: { startedAt: string; completedAt?: string }[],
|
||||||
|
|
@ -236,7 +303,7 @@ export class TaskChangeComputer {
|
||||||
const toolUseIdsSet = new Set<string>();
|
const toolUseIdsSet = new Set<string>();
|
||||||
|
|
||||||
for (const { snippets } of allParsed) {
|
for (const { snippets } of allParsed) {
|
||||||
for (const snippet of snippets) {
|
for (const { snippet } of snippets) {
|
||||||
if (snippet.isError) continue;
|
if (snippet.isError) continue;
|
||||||
if (!inAnyInterval(snippet.timestamp)) continue;
|
if (!inAnyInterval(snippet.timestamp)) continue;
|
||||||
allowedSnippets.push(snippet);
|
allowedSnippets.push(snippet);
|
||||||
|
|
@ -258,24 +325,29 @@ export class TaskChangeComputer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractFilteredChanges(
|
private async extractScopedChanges(
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
allowedToolUseIds: Set<string>,
|
scopes: TaskChangeScope[],
|
||||||
projectPath?: string,
|
projectPath?: string,
|
||||||
includeDetails = true
|
includeDetails = true
|
||||||
): Promise<FileChangeSummary[]> {
|
): 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 allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||||
const allSnippets: SnippetDiff[] = [];
|
const allSnippets: SnippetDiff[] = [];
|
||||||
|
|
||||||
for (const { snippets } of allParsed) {
|
for (let index = 0; index < allParsed.length; index++) {
|
||||||
if (allowedToolUseIds.size > 0) {
|
const ref = logRefs[index];
|
||||||
for (const snippet of snippets) {
|
const parsed = allParsed[index];
|
||||||
if (allowedToolUseIds.has(snippet.toolUseId)) {
|
if (!ref || !parsed) continue;
|
||||||
allSnippets.push(snippet);
|
const matchingScopes = this.selectScopesForLogRef(scopesWithTools, ref);
|
||||||
}
|
if (matchingScopes.length === 0) continue;
|
||||||
}
|
|
||||||
} else {
|
for (const record of parsed.snippets) {
|
||||||
allSnippets.push(...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(
|
private async fallbackSingleTaskScope(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
@ -295,7 +413,7 @@ export class TaskChangeComputer {
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
const allParsed = await this.parseJSONLFilesWithConcurrency(logRefs.map((ref) => ref.filePath));
|
||||||
const allSnippets = this.sortSnippetsChronologically(
|
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);
|
const aggregatedFiles = this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||||
|
|
||||||
|
|
@ -319,7 +437,7 @@ export class TaskChangeComputer {
|
||||||
filePaths: aggregatedFiles.map((file) => file.filePath),
|
filePaths: aggregatedFiles.map((file) => file.filePath),
|
||||||
confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' },
|
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(
|
private async parseJSONLFilesWithConcurrency(paths: string[]): Promise<ParsedSnippetsResult[]> {
|
||||||
paths: string[]
|
|
||||||
): Promise<{ snippets: SnippetDiff[]; mtime: number }[]> {
|
|
||||||
if (paths.length === 0) return [];
|
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;
|
let nextIndex = 0;
|
||||||
|
|
||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
|
|
@ -405,17 +521,22 @@ export class TaskChangeComputer {
|
||||||
filePath: string,
|
filePath: string,
|
||||||
fileMtime: number
|
fileMtime: number
|
||||||
): Promise<ParsedSnippetsResult> {
|
): Promise<ParsedSnippetsResult> {
|
||||||
const entries: Record<string, unknown>[] = [];
|
const entries: ParsedJsonlEntry[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
|
let lineNumber = 0;
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
|
lineNumber += 1;
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
try {
|
try {
|
||||||
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
|
entries.push({
|
||||||
|
entry: JSON.parse(trimmed) as Record<string, unknown>,
|
||||||
|
lineNumber,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore invalid JSON lines.
|
// Ignore invalid JSON lines.
|
||||||
}
|
}
|
||||||
|
|
@ -428,11 +549,11 @@ export class TaskChangeComputer {
|
||||||
return { snippets: [], mtime: 0 };
|
return { snippets: [], mtime: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const erroredIds = this.collectErroredToolUseIds(entries);
|
const erroredIds = this.collectErroredToolUseIds(entries.map((record) => record.entry));
|
||||||
const snippets: SnippetDiff[] = [];
|
const snippets: ParsedSnippetRecord[] = [];
|
||||||
const seenFiles = new Set<string>();
|
const seenFiles = new Set<string>();
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const { entry, lineNumber } of entries) {
|
||||||
const role = this.extractRole(entry);
|
const role = this.extractRole(entry);
|
||||||
if (role !== 'assistant') continue;
|
if (role !== 'assistant') continue;
|
||||||
|
|
||||||
|
|
@ -459,18 +580,26 @@ export class TaskChangeComputer {
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
||||||
const isError = erroredIds.has(toolUseId);
|
const isError = erroredIds.has(toolUseId);
|
||||||
|
const addSnippet = (snippet: SnippetDiff): void => {
|
||||||
|
snippets.push({ snippet, sourceLine: lineNumber });
|
||||||
|
};
|
||||||
|
|
||||||
if (toolName === 'Edit') {
|
if (toolName === 'Edit') {
|
||||||
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
const targetPath = typeof input.file_path === 'string' ? input.file_path : '';
|
||||||
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
|
||||||
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
const newString = typeof input.new_string === 'string' ? input.new_string : '';
|
||||||
const replaceAll = input.replace_all === true;
|
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) {
|
for (const target of targetPaths) {
|
||||||
seenFiles.add(this.normalizeFilePathKey(targetPath));
|
seenFiles.add(this.normalizeFilePathKey(target.filePath));
|
||||||
snippets.push({
|
addSnippet({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: targetPath,
|
filePath: target.filePath,
|
||||||
toolName: 'Edit',
|
toolName: 'Edit',
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
oldString,
|
oldString,
|
||||||
|
|
@ -489,7 +618,7 @@ export class TaskChangeComputer {
|
||||||
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
|
const normalizedTargetPath = this.normalizeFilePathKey(targetPath);
|
||||||
const isNew = !seenFiles.has(normalizedTargetPath);
|
const isNew = !seenFiles.has(normalizedTargetPath);
|
||||||
seenFiles.add(normalizedTargetPath);
|
seenFiles.add(normalizedTargetPath);
|
||||||
snippets.push({
|
addSnippet({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: targetPath,
|
filePath: targetPath,
|
||||||
toolName: 'Write',
|
toolName: 'Write',
|
||||||
|
|
@ -513,7 +642,7 @@ export class TaskChangeComputer {
|
||||||
const editObj = edit as Record<string, unknown>;
|
const editObj = edit as Record<string, unknown>;
|
||||||
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
|
const oldString = typeof editObj.old_string === 'string' ? editObj.old_string : '';
|
||||||
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
const newString = typeof editObj.new_string === 'string' ? editObj.new_string : '';
|
||||||
snippets.push({
|
addSnippet({
|
||||||
toolUseId,
|
toolUseId,
|
||||||
filePath: targetPath,
|
filePath: targetPath,
|
||||||
toolName: 'MultiEdit',
|
toolName: 'MultiEdit',
|
||||||
|
|
@ -559,6 +688,25 @@ export class TaskChangeComputer {
|
||||||
return null;
|
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> {
|
private collectErroredToolUseIds(entries: Record<string, unknown>[]): Set<string> {
|
||||||
const erroredIds = new Set<string>();
|
const erroredIds = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -693,6 +841,7 @@ export class TaskChangeComputer {
|
||||||
}
|
}
|
||||||
case 'edit': {
|
case 'edit': {
|
||||||
const { added, removed } = countLineChanges(snippet.oldString, snippet.newString);
|
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.oldString === '') return `Added ${added} line${added !== 1 ? 's' : ''}`;
|
||||||
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
|
if (snippet.newString === '') return `Removed ${removed} line${removed !== 1 ? 's' : ''}`;
|
||||||
return `Changed ${removed} → ${added} lines`;
|
return `Changed ${removed} → ${added} lines`;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { FileEditTimeline } from './FileEditTimeline';
|
||||||
import { buildInitialReviewFileScrollKey } from './initialReviewFileScroll';
|
import { buildInitialReviewFileScrollKey } from './initialReviewFileScroll';
|
||||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||||
import { buildPathChangeLabels } from './pathChangeLabels';
|
import { buildPathChangeLabels } from './pathChangeLabels';
|
||||||
|
import { getResolvedReviewModifiedContent, isReviewRejectable } from './reviewContentPreview';
|
||||||
import { resolveReviewFilePath } from './reviewFilePathResolution';
|
import { resolveReviewFilePath } from './reviewFilePathResolution';
|
||||||
import { ReviewFileTree } from './ReviewFileTree';
|
import { ReviewFileTree } from './ReviewFileTree';
|
||||||
import { ReviewToolbar } from './ReviewToolbar';
|
import { ReviewToolbar } from './ReviewToolbar';
|
||||||
|
|
@ -185,7 +186,7 @@ export const ChangeReviewDialog = ({
|
||||||
globalTasks,
|
globalTasks,
|
||||||
} = useStore();
|
} = 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 ?? ''}`;
|
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
|
||||||
// Filesystem-safe: use `-` instead of `:` for decision persistence key
|
// Filesystem-safe: use `-` instead of `:` for decision persistence key
|
||||||
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
|
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
|
||||||
|
|
@ -242,7 +243,7 @@ export const ChangeReviewDialog = ({
|
||||||
// EditorView map for all visible file editors
|
// EditorView map for all visible file editors
|
||||||
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
const editorViewMapRef = useRef(new Map<string, EditorView>());
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
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);
|
const lastFocusedEditorRef = useRef<EditorView | null>(null);
|
||||||
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
|
// Timestamp of last bulk accept/reject-all operation (for Ctrl/Cmd+Z UX)
|
||||||
const lastBulkActionAtRef = useRef<number>(0);
|
const lastBulkActionAtRef = useRef<number>(0);
|
||||||
|
|
@ -330,6 +331,18 @@ export const ChangeReviewDialog = ({
|
||||||
return buildPathChangeLabels(activeChangeSet?.files ?? [], fileContents);
|
return buildPathChangeLabels(activeChangeSet?.files ?? [], fileContents);
|
||||||
}, [activeChangeSet, 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 {
|
const {
|
||||||
viewedSet,
|
viewedSet,
|
||||||
isViewed,
|
isViewed,
|
||||||
|
|
@ -426,9 +439,12 @@ export const ChangeReviewDialog = ({
|
||||||
|
|
||||||
const handleRejectAll = useCallback(() => {
|
const handleRejectAll = useCallback(() => {
|
||||||
if (!activeChangeSet) return;
|
if (!activeChangeSet) return;
|
||||||
|
const rejectableFilePaths = new Set(rejectablePendingFiles.map((file) => file.filePath));
|
||||||
|
if (rejectableFilePaths.size === 0) return;
|
||||||
pushReviewUndoSnapshot();
|
pushReviewUndoSnapshot();
|
||||||
lastBulkActionAtRef.current = Date.now();
|
lastBulkActionAtRef.current = Date.now();
|
||||||
for (const file of activeChangeSet.files) {
|
for (const file of activeChangeSet.files) {
|
||||||
|
if (!rejectableFilePaths.has(file.filePath)) continue;
|
||||||
rejectAllFile(file.filePath);
|
rejectAllFile(file.filePath);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -443,6 +459,7 @@ export const ChangeReviewDialog = ({
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeChangeSet,
|
activeChangeSet,
|
||||||
|
rejectablePendingFiles,
|
||||||
rejectAllFile,
|
rejectAllFile,
|
||||||
pushReviewUndoSnapshot,
|
pushReviewUndoSnapshot,
|
||||||
applyReview,
|
applyReview,
|
||||||
|
|
@ -489,16 +506,8 @@ export const ChangeReviewDialog = ({
|
||||||
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
|
const hasErrorForFile = !!result?.errors.some((e) => e.filePath === filePath);
|
||||||
if (result && !hasErrorForFile && file) {
|
if (result && !hasErrorForFile && file) {
|
||||||
// Keep undo payload so Ctrl/Cmd+Z can restore the file (and re-add it to the review list).
|
// 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 =
|
const restoreContent =
|
||||||
cachedModified ??
|
getResolvedReviewModifiedContent(file, fileContents[filePath] ?? null) ?? '';
|
||||||
(() => {
|
|
||||||
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;
|
|
||||||
})();
|
|
||||||
const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0;
|
const index = activeChangeSet?.files.findIndex((f) => f.filePath === filePath) ?? 0;
|
||||||
removedNewFileUndoStackRef.current.push({
|
removedNewFileUndoStackRef.current.push({
|
||||||
file,
|
file,
|
||||||
|
|
@ -688,7 +697,7 @@ export const ChangeReviewDialog = ({
|
||||||
}, SELECTION_DEBOUNCE_MS);
|
}, 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;
|
const hasData = !changeSetLoading && !changeSetError && !!activeChangeSet;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasData) return;
|
if (!hasData) return;
|
||||||
|
|
@ -832,7 +841,7 @@ export const ChangeReviewDialog = ({
|
||||||
void fetchTaskChanges(teamName, taskId, taskChangeRequestOptions ?? {});
|
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();
|
return () => clearChangeReviewCache();
|
||||||
}, [
|
}, [
|
||||||
open,
|
open,
|
||||||
|
|
@ -1110,6 +1119,7 @@ export const ChangeReviewDialog = ({
|
||||||
addReviewFile,
|
addReviewFile,
|
||||||
updateEditedContent,
|
updateEditedContent,
|
||||||
saveEditedFile,
|
saveEditedFile,
|
||||||
|
markRecentReviewWrite,
|
||||||
projectPath,
|
projectPath,
|
||||||
scheduleScrollToFile,
|
scheduleScrollToFile,
|
||||||
]);
|
]);
|
||||||
|
|
@ -1277,6 +1287,7 @@ export const ChangeReviewDialog = ({
|
||||||
onRejectAll={handleRejectAll}
|
onRejectAll={handleRejectAll}
|
||||||
onApply={handleApply}
|
onApply={handleApply}
|
||||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||||
|
canRejectAll={canRejectAll}
|
||||||
instantApply={REVIEW_INSTANT_APPLY}
|
instantApply={REVIEW_INSTANT_APPLY}
|
||||||
editedCount={editedCount}
|
editedCount={editedCount}
|
||||||
canUndo={reviewUndoStack.length > 0}
|
canUndo={reviewUndoStack.length > 0}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
|
||||||
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
import { DiffErrorBoundary } from './DiffErrorBoundary';
|
||||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||||
|
import {
|
||||||
|
getResolvedReviewModifiedContent,
|
||||||
|
hasReviewSnippetText,
|
||||||
|
isReviewFileMissingOnDisk,
|
||||||
|
isReviewTextContentUnavailable,
|
||||||
|
shouldRenderCurrentDiskContextPreview,
|
||||||
|
} from './reviewContentPreview';
|
||||||
import { ReviewDiffContent } from './ReviewDiffContent';
|
import { ReviewDiffContent } from './ReviewDiffContent';
|
||||||
import {
|
import {
|
||||||
shouldRenderCodeMirrorReviewDiff,
|
shouldRenderCodeMirrorReviewDiff,
|
||||||
|
|
@ -51,7 +58,8 @@ export const FileSectionDiff = ({
|
||||||
}: FileSectionDiffProps): React.ReactElement => {
|
}: FileSectionDiffProps): React.ReactElement => {
|
||||||
const localEditorViewRef = useRef<EditorView | null>(null);
|
const localEditorViewRef = useRef<EditorView | null>(null);
|
||||||
const sentinelRef = useRef<HTMLDivElement>(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.
|
// Notify parent whenever CodeMirrorDiffView creates or destroys its EditorView.
|
||||||
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
// This fires on every editor lifecycle event: initial mount, key-change remount,
|
||||||
|
|
@ -85,8 +93,7 @@ export const FileSectionDiff = ({
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
const hasSnippetPreview = file.snippets.some((snippet) => !snippet.isError);
|
if (!hasSnippetText) {
|
||||||
if (!hasSnippetPreview) {
|
|
||||||
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,21 +110,12 @@ export const FileSectionDiff = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve modified content: prefer full content, fall back to write-type snippet
|
// 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
|
// Only write-new/write-update snippets contain the full file - edit snippets are partial
|
||||||
const resolvedModified =
|
const resolvedModified = getResolvedReviewModifiedContent(file, fileContent);
|
||||||
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;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||||
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
|
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
|
||||||
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
|
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
|
||||||
const hasLedgerManualAction = file.snippets.some(
|
const hasLedgerManualAction = file.snippets.some(
|
||||||
(snippet) =>
|
(snippet) =>
|
||||||
!!snippet.ledger &&
|
!!snippet.ledger &&
|
||||||
|
|
@ -138,22 +136,64 @@ export const FileSectionDiff = ({
|
||||||
const canRenderCodeMirrorSafely =
|
const canRenderCodeMirrorSafely =
|
||||||
canRenderCodeMirror &&
|
canRenderCodeMirror &&
|
||||||
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
|
shouldRenderCodeMirrorReviewDiff(originalForDiff, resolvedModified ?? '');
|
||||||
|
const canRenderCurrentDiskContext =
|
||||||
|
resolvedModified !== null &&
|
||||||
|
shouldRenderCurrentDiskContextPreview(file, fileContent) &&
|
||||||
|
shouldRenderCodeMirrorReviewDiff(resolvedModified, resolvedModified);
|
||||||
|
const currentDiskContextContent = canRenderCurrentDiskContext ? resolvedModified : null;
|
||||||
|
|
||||||
if (!canRenderCodeMirrorSafely) {
|
if (!canRenderCodeMirrorSafely) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<OversizedDiffNotice
|
<OversizedDiffNotice
|
||||||
message={
|
message={
|
||||||
hasLedgerManualAction || isContentUnavailable
|
canRenderCurrentDiskContext
|
||||||
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
|
? 'No original baseline is available; showing current disk content for context only. Reject is disabled for this file.'
|
||||||
: canRenderCodeMirror && !canRenderSnippetPreview
|
: hasLedgerManualAction || isContentUnavailable
|
||||||
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
? 'No text diff is available for this ledger change. Binary, large, or metadata-only content requires manual review.'
|
||||||
: canRenderCodeMirror
|
: canRenderCodeMirror && !canRenderSnippetPreview
|
||||||
? 'Large diff opened in safe preview mode to avoid a renderer out-of-memory crash.'
|
? 'Full diff skipped because it is large enough to risk a renderer out-of-memory crash.'
|
||||||
: 'Diff preview skipped because the available change data is too large to render safely.'
|
: 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 ref={sentinelRef} className="h-1 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||||
import { ChevronDown, ChevronRight, FilePlus, GitBranch, Loader2, Save, Undo2 } from 'lucide-react';
|
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 { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
||||||
|
|
@ -57,32 +65,15 @@ export const FileSectionHeader = ({
|
||||||
onAcceptFile,
|
onAcceptFile,
|
||||||
onRejectFile,
|
onRejectFile,
|
||||||
}: FileSectionHeaderProps): React.ReactElement => {
|
}: FileSectionHeaderProps): React.ReactElement => {
|
||||||
const isMissingOnDisk = fileContent ? fileContent.modifiedFullContent == null : false;
|
const restoreContent = getResolvedReviewModifiedContent(file, fileContent);
|
||||||
const isContentUnavailable = fileContent?.contentSource === 'unavailable';
|
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
|
||||||
|
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
|
||||||
const isPreviewOnly = isMissingOnDisk || isContentUnavailable;
|
const isPreviewOnly = isMissingOnDisk || isContentUnavailable;
|
||||||
const requiresManualLedgerReview = file.snippets.some(
|
const manualLedgerReviewRequired = requiresManualLedgerReview(file);
|
||||||
(snippet) =>
|
const rejectBlockReason = getReviewRejectBlockReason(file, fileContent);
|
||||||
!!snippet.ledger &&
|
const rejectDisabled = rejectBlockReason !== null;
|
||||||
(!!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 canRestore =
|
const canRestore =
|
||||||
!!onRestoreMissingFile &&
|
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
|
||||||
isMissingOnDisk &&
|
|
||||||
!isContentUnavailable &&
|
|
||||||
!hasEdits &&
|
|
||||||
restoreContent != null;
|
|
||||||
const externalChangeLabel =
|
const externalChangeLabel =
|
||||||
externalChange?.type === 'unlink'
|
externalChange?.type === 'unlink'
|
||||||
? 'Deleted on disk'
|
? 'Deleted on disk'
|
||||||
|
|
@ -240,7 +231,7 @@ export const FileSectionHeader = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{requiresManualLedgerReview && (
|
{manualLedgerReviewRequired && (
|
||||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
|
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||||
MANUAL REVIEW
|
MANUAL REVIEW
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -315,11 +306,13 @@ export const FileSectionHeader = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{rejectDisabled && (
|
{rejectDisabled && (
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{requiresManualLedgerReview
|
{rejectBlockReason === 'manual-ledger-review'
|
||||||
? 'Reject is disabled because this ledger change has binary, large, or unavailable content.'
|
? '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.'
|
? '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>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ interface ReviewToolbarProps {
|
||||||
onRejectAll: () => void;
|
onRejectAll: () => void;
|
||||||
onApply: () => void;
|
onApply: () => void;
|
||||||
onCollapseUnchangedChange: (collapse: boolean) => void;
|
onCollapseUnchangedChange: (collapse: boolean) => void;
|
||||||
|
canRejectAll?: boolean;
|
||||||
editedCount?: number;
|
editedCount?: number;
|
||||||
canUndo?: boolean;
|
canUndo?: boolean;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
|
|
@ -34,6 +35,7 @@ export const ReviewToolbar = ({
|
||||||
onRejectAll,
|
onRejectAll,
|
||||||
onApply,
|
onApply,
|
||||||
onCollapseUnchangedChange: _onCollapseUnchangedChange,
|
onCollapseUnchangedChange: _onCollapseUnchangedChange,
|
||||||
|
canRejectAll = true,
|
||||||
instantApply = false,
|
instantApply = false,
|
||||||
editedCount = 0,
|
editedCount = 0,
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
|
|
@ -43,6 +45,7 @@ export const ReviewToolbar = ({
|
||||||
const canApply = hasRejected && !applying;
|
const canApply = hasRejected && !applying;
|
||||||
const totalChanges = stats.pending + stats.accepted + stats.rejected;
|
const totalChanges = stats.pending + stats.accepted + stats.rejected;
|
||||||
const reviewedCount = stats.accepted + stats.rejected;
|
const reviewedCount = stats.accepted + stats.rejected;
|
||||||
|
const rejectAllDisabled = applying || !canRejectAll;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 border-b border-border bg-surface-sidebar px-4 py-2">
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<span>
|
||||||
onClick={onRejectAll}
|
<button
|
||||||
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"
|
onClick={onRejectAll}
|
||||||
>
|
disabled={rejectAllDisabled}
|
||||||
<X className="size-3" />
|
className={cn(
|
||||||
Reject All
|
'flex items-center gap-1 rounded px-2.5 py-1 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
</button>
|
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>
|
</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>
|
</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.taskId)).toEqual(['task-123', 'task-123']);
|
||||||
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
|
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 {
|
return {
|
||||||
timestamp: '2026-03-01T10:00:00.000Z',
|
timestamp,
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
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', () => {
|
describe('TaskChangeComputer', () => {
|
||||||
let tmpDir: string | null = null;
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
|
|
@ -90,4 +141,356 @@ describe('TaskChangeComputer', () => {
|
||||||
.sort((left, right) => left.localeCompare(right))
|
.sort((left, right) => left.localeCompare(right))
|
||||||
).toEqual(['src/a.ts', 'src/b.ts']);
|
).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 () => {
|
it('caps long visible rows while preserving the full preview in the title', async () => {
|
||||||
const node: GraphNode = {
|
const node: GraphNode = {
|
||||||
id: 'member:alpha-team:alice',
|
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