465 lines
17 KiB
TypeScript
465 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
|
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
|
import { useStore } from '@renderer/store';
|
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
|
import { isOpenCodeRelaunchActionable } from '@renderer/utils/memberHelpers';
|
|
import {
|
|
buildMemberLaunchDiagnosticsPayload,
|
|
getMemberLaunchDiagnosticsErrorMessage,
|
|
hasMemberLaunchDiagnosticsDetails,
|
|
hasMemberLaunchDiagnosticsError,
|
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
|
import {
|
|
getRuntimeMemorySourceLabel,
|
|
resolveMemberRuntimeSummary,
|
|
} from '@renderer/utils/memberRuntimeSummary';
|
|
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
|
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
|
import { isTeamTaskFinishedForDependency } from '@shared/utils/teamTaskState';
|
|
import {
|
|
BarChart3,
|
|
FileText,
|
|
ListPlus,
|
|
Loader2,
|
|
MessageSquare,
|
|
RotateCcw,
|
|
UserMinus,
|
|
} from 'lucide-react';
|
|
|
|
import { buildMemberActivityEntries } from './memberActivityEntries';
|
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
|
import { MemberDetailStats } from './MemberDetailStats';
|
|
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
|
import { MemberLogsTab } from './MemberLogsTab';
|
|
import { MemberMessagesTab } from './MemberMessagesTab';
|
|
import { MemberStatsTab } from './MemberStatsTab';
|
|
import { MemberTasksTab } from './MemberTasksTab';
|
|
|
|
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
|
import type {
|
|
LeadActivityState,
|
|
MemberSpawnStatusEntry,
|
|
ResolvedTeamMember,
|
|
TeamAgentRuntimeEntry,
|
|
TeamTaskWithKanban,
|
|
} from '@shared/types';
|
|
|
|
const OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE =
|
|
'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.';
|
|
const OPENCODE_BOOTSTRAP_STALLED_MESSAGE =
|
|
'OpenCode process is alive, but bootstrap did not confirm. Relaunch this teammate to start a fresh OpenCode session.';
|
|
|
|
function hasOpenCodeRuntimeEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean {
|
|
const hasPid =
|
|
typeof runtimeEntry?.pid === 'number' &&
|
|
Number.isFinite(runtimeEntry.pid) &&
|
|
runtimeEntry.pid > 0;
|
|
const hasRuntimePid =
|
|
typeof runtimeEntry?.runtimePid === 'number' &&
|
|
Number.isFinite(runtimeEntry.runtimePid) &&
|
|
runtimeEntry.runtimePid > 0;
|
|
const hasRuntimeSessionId =
|
|
typeof runtimeEntry?.runtimeSessionId === 'string' &&
|
|
runtimeEntry.runtimeSessionId.trim().length > 0;
|
|
const hasRuntimeLiveness =
|
|
runtimeEntry?.livenessKind === 'runtime_process' ||
|
|
runtimeEntry?.livenessKind === 'runtime_process_candidate' ||
|
|
runtimeEntry?.livenessKind === 'permission_blocked';
|
|
return Boolean(hasPid || hasRuntimePid || hasRuntimeSessionId || hasRuntimeLiveness);
|
|
}
|
|
|
|
function isOpenCodeNoRuntimeEvidenceFailure(
|
|
member: ResolvedTeamMember,
|
|
spawnEntry: MemberSpawnStatusEntry | undefined,
|
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
|
): boolean {
|
|
const failed = spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error';
|
|
return member.providerId === 'opencode' && failed && !hasOpenCodeRuntimeEvidence(runtimeEntry);
|
|
}
|
|
|
|
interface MemberDetailDialogProps {
|
|
open: boolean;
|
|
member: ResolvedTeamMember | null;
|
|
teamName: string;
|
|
members: ResolvedTeamMember[];
|
|
tasks: TeamTaskWithKanban[];
|
|
initialTab?: MemberDetailTab;
|
|
initialActivityFilter?: MemberActivityFilter;
|
|
isTeamAlive?: boolean;
|
|
isTeamProvisioning?: boolean;
|
|
isLaunchSettling?: boolean;
|
|
leadActivity?: LeadActivityState;
|
|
spawnEntry?: MemberSpawnStatusEntry;
|
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
|
runtimeRunId?: string | null;
|
|
launchParams?: TeamLaunchParams;
|
|
onClose: () => void;
|
|
onSendMessage: () => void;
|
|
onAssignTask: () => void;
|
|
onTaskClick: (task: TeamTaskWithKanban) => void;
|
|
onRemoveMember?: () => void;
|
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
|
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
|
|
updatingRole?: boolean;
|
|
onViewMemberChanges?: (memberName: string, filePath?: string) => void;
|
|
}
|
|
|
|
export const MemberDetailDialog = ({
|
|
open,
|
|
member,
|
|
teamName,
|
|
members,
|
|
tasks,
|
|
initialTab = 'tasks',
|
|
initialActivityFilter = 'all',
|
|
isTeamAlive,
|
|
isTeamProvisioning,
|
|
isLaunchSettling,
|
|
leadActivity,
|
|
spawnEntry,
|
|
runtimeEntry,
|
|
runtimeRunId,
|
|
launchParams,
|
|
onClose,
|
|
onSendMessage,
|
|
onAssignTask,
|
|
onTaskClick,
|
|
onRemoveMember,
|
|
onRestartMember,
|
|
onUpdateRole,
|
|
updatingRole,
|
|
onViewMemberChanges,
|
|
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
|
const memberTasks = useMemo(
|
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
|
[tasks, member]
|
|
);
|
|
const memberMessages = useStore((state) =>
|
|
selectMemberMessagesForTeamMember(state, teamName, member?.name ?? null)
|
|
);
|
|
const memberActivityCount = useMemo(() => {
|
|
if (!member) {
|
|
return 0;
|
|
}
|
|
return buildMemberActivityEntries({
|
|
teamName,
|
|
memberName: member.name,
|
|
members,
|
|
tasks,
|
|
messages: memberMessages,
|
|
}).length;
|
|
}, [member, memberMessages, members, tasks, teamName]);
|
|
|
|
const inProgressTasks = useMemo(
|
|
() => memberTasks.filter(isDisplayableCurrentTask).length,
|
|
[memberTasks]
|
|
);
|
|
const currentTaskCandidate = useMemo(
|
|
() =>
|
|
member?.currentTaskId
|
|
? (tasks.find((task) => task.id === member.currentTaskId) ?? null)
|
|
: null,
|
|
[member?.currentTaskId, tasks]
|
|
);
|
|
const displayableCurrentTask = isDisplayableCurrentTask(currentTaskCandidate)
|
|
? currentTaskCandidate
|
|
: null;
|
|
|
|
const completedTasks = useMemo(
|
|
() => memberTasks.filter(isTeamTaskFinishedForDependency).length,
|
|
[memberTasks]
|
|
);
|
|
|
|
const [activeTab, setActiveTab] = useState<MemberDetailTab>(initialTab);
|
|
const [restarting, setRestarting] = useState(false);
|
|
const [restartError, setRestartError] = useState<string | null>(null);
|
|
|
|
const runtimeSummary = useMemo(
|
|
() =>
|
|
member
|
|
? resolveMemberRuntimeSummary(member, launchParams, spawnEntry, runtimeEntry)
|
|
: undefined,
|
|
[launchParams, member, runtimeEntry, spawnEntry]
|
|
);
|
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
|
const openCodeRelaunchActionable = member
|
|
? isOpenCodeRelaunchActionable({ member, spawnEntry, runtimeEntry })
|
|
: false;
|
|
const restartInFlight =
|
|
!openCodeRelaunchActionable &&
|
|
(spawnEntry?.launchState === 'starting' ||
|
|
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
|
spawnEntry?.launchState === 'runtime_pending_permission');
|
|
const launchDiagnosticsPayload = useMemo(
|
|
() =>
|
|
member
|
|
? buildMemberLaunchDiagnosticsPayload({
|
|
teamName,
|
|
runId: runtimeRunId,
|
|
memberName: member.name,
|
|
spawnEntry,
|
|
runtimeEntry,
|
|
})
|
|
: null,
|
|
[member, runtimeEntry, runtimeRunId, spawnEntry, teamName]
|
|
);
|
|
const showCopyDiagnostics =
|
|
launchDiagnosticsPayload != null &&
|
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
|
const launchErrorMessage = launchDiagnosticsPayload
|
|
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
|
: undefined;
|
|
const openCodeBootstrapStalled =
|
|
member?.providerId === 'opencode' && spawnEntry?.bootstrapStalled === true;
|
|
const openCodeNoRuntimeEvidence = member
|
|
? isOpenCodeNoRuntimeEvidenceFailure(member, spawnEntry, runtimeEntry)
|
|
: false;
|
|
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
|
|
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
|
|
: launchErrorMessage;
|
|
const effectiveLaunchInfoMessage = openCodeBootstrapStalled
|
|
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
|
|
: undefined;
|
|
const isOpenCodeMember = member?.providerId === 'opencode';
|
|
const restartButtonLabel = isOpenCodeMember ? 'Relaunch OpenCode' : 'Restart';
|
|
const hasLiveRestartContext = isTeamAlive === true || isTeamProvisioning === true;
|
|
const canControlledOpenCodeRelaunch =
|
|
member == null
|
|
? false
|
|
: isOpenCodeMember && !member.removedAt && !isLeadMember(member) && hasLiveRestartContext;
|
|
const canRestartFromDialog =
|
|
member == null
|
|
? false
|
|
: Boolean(onRestartMember) &&
|
|
!isLeadMember(member) &&
|
|
hasLiveRestartContext &&
|
|
(runtimeEntry?.restartable !== false || canControlledOpenCodeRelaunch);
|
|
|
|
useEffect(() => {
|
|
if (!open || !member) {
|
|
return;
|
|
}
|
|
setActiveTab(initialTab);
|
|
setRestartError(null);
|
|
setRestarting(false);
|
|
}, [initialTab, member, open]);
|
|
|
|
const {
|
|
stats: memberStats,
|
|
loading: statsLoading,
|
|
error: statsError,
|
|
} = useMemberStats(teamName, member?.name ?? null);
|
|
|
|
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
|
|
|
|
if (!member) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
|
<DialogContent className="min-w-0 sm:max-w-4xl">
|
|
<div className="flex items-start gap-4">
|
|
<DialogHeader className="shrink-0">
|
|
<MemberDetailHeader
|
|
member={
|
|
member.currentTaskId && !displayableCurrentTask
|
|
? { ...member, currentTaskId: null }
|
|
: member
|
|
}
|
|
runtimeSummary={runtimeSummary}
|
|
isTeamAlive={isTeamAlive}
|
|
isTeamProvisioning={isTeamProvisioning}
|
|
leadActivity={isLeadMember(member) ? leadActivity : undefined}
|
|
spawnStatus={spawnEntry?.status}
|
|
spawnLaunchState={spawnEntry?.launchState}
|
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
|
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
|
|
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
|
runtimeEntry={runtimeEntry}
|
|
isLaunchSettling={isLaunchSettling}
|
|
onUpdateRole={
|
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
|
}
|
|
updatingRole={updatingRole}
|
|
/>
|
|
</DialogHeader>
|
|
|
|
<MemberDetailStats
|
|
totalTasks={memberTasks.length}
|
|
inProgressTasks={inProgressTasks}
|
|
completedTasks={completedTasks}
|
|
activityCount={memberActivityCount}
|
|
totalTokens={totalTokens}
|
|
statsLoading={statsLoading}
|
|
statsComputedAt={memberStats?.computedAt}
|
|
onTabChange={setActiveTab}
|
|
/>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) => setActiveTab(v as MemberDetailTab)}
|
|
className="min-w-0 overflow-hidden"
|
|
>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="tasks" className="flex-1 gap-1.5">
|
|
Tasks
|
|
{memberTasks.length > 0 && (
|
|
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
|
|
{memberTasks.length}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="activity" className="flex-1 gap-1.5">
|
|
Activity
|
|
{memberActivityCount > 0 && (
|
|
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
|
|
{memberActivityCount}
|
|
</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="stats" className="flex-1 gap-1.5">
|
|
<BarChart3 size={12} />
|
|
Stats
|
|
</TabsTrigger>
|
|
<TabsTrigger value="logs" className="flex-1 gap-1.5">
|
|
<FileText size={12} />
|
|
Logs
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="tasks">
|
|
<div className="space-y-3">
|
|
{/*
|
|
<MemberWorkSyncStatusPanel
|
|
teamName={teamName}
|
|
memberName={member.name}
|
|
enabled={open && !member.removedAt}
|
|
/>
|
|
*/}
|
|
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="activity">
|
|
<MemberMessagesTab
|
|
teamName={teamName}
|
|
memberName={member.name}
|
|
members={members}
|
|
tasks={tasks}
|
|
initialFilter={initialActivityFilter}
|
|
onTaskClick={onTaskClick}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="stats">
|
|
<MemberStatsTab
|
|
teamName={teamName}
|
|
memberName={member.name}
|
|
prefetchedStats={memberStats}
|
|
prefetchedLoading={statsLoading}
|
|
prefetchedError={statsError}
|
|
onFileClick={(filePath) => onViewMemberChanges?.(member.name, filePath)}
|
|
onShowAllFiles={() => onViewMemberChanges?.(member.name)}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
|
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<DialogFooter>
|
|
{restartError ? (
|
|
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
|
) : effectiveLaunchErrorMessage ? (
|
|
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
|
<span className="min-w-0 truncate" title={effectiveLaunchErrorMessage}>
|
|
{effectiveLaunchErrorMessage}
|
|
</span>
|
|
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
|
<MemberLaunchDiagnosticsButton
|
|
payload={launchDiagnosticsPayload}
|
|
label="Copy diagnostics"
|
|
className="h-auto shrink-0 gap-1.5 px-2 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : effectiveLaunchInfoMessage ? (
|
|
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-amber-300">
|
|
<span className="min-w-0 truncate" title={effectiveLaunchInfoMessage}>
|
|
{effectiveLaunchInfoMessage}
|
|
</span>
|
|
</div>
|
|
) : runtimeEntry?.pid ? (
|
|
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
|
PID {runtimeEntry.pid}
|
|
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
|
|
</div>
|
|
) : (
|
|
<div className="mr-auto" />
|
|
)}
|
|
{member.removedAt ? (
|
|
<span className="text-xs text-[var(--color-text-muted)]">
|
|
Removed {new Date(member.removedAt).toLocaleDateString()}
|
|
</span>
|
|
) : (
|
|
<>
|
|
{canRestartFromDialog && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5"
|
|
disabled={restarting || restartInFlight}
|
|
onClick={async () => {
|
|
setRestartError(null);
|
|
setRestarting(true);
|
|
try {
|
|
if (!onRestartMember) return;
|
|
await onRestartMember(member.name);
|
|
} catch (error) {
|
|
setRestartError(
|
|
error instanceof Error ? error.message : 'Failed to restart member'
|
|
);
|
|
} finally {
|
|
setRestarting(false);
|
|
}
|
|
}}
|
|
>
|
|
{restarting ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<RotateCcw size={14} />
|
|
)}
|
|
{restartButtonLabel}
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
|
<MessageSquare size={14} />
|
|
Send Message
|
|
</Button>
|
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
|
|
<ListPlus size={14} />
|
|
Assign Task
|
|
</Button>
|
|
{onRemoveMember && !isLeadMember(member) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
|
onClick={onRemoveMember}
|
|
>
|
|
<UserMinus size={14} />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|