perf(renderer): split noisy team detail subscriptions

This commit is contained in:
iliya 2026-04-09 19:00:22 +03:00
parent 0656794026
commit affd9ac748

View file

@ -1,4 +1,5 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentProps } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
@ -265,6 +266,160 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
); );
}); });
type TeamMessagesPanelBridgeProps = Omit<
ComponentProps<typeof MessagesPanel>,
'leadActivity' | 'leadContextUpdatedAt'
>;
type SharedTeamMessagesPanelProps = Omit<TeamMessagesPanelBridgeProps, 'position'>;
type TeamMemberListBridgeProps = Omit<
ComponentProps<typeof MemberList>,
'leadActivity' | 'memberSpawnStatuses'
> & {
teamName: string;
};
type TeamMemberDetailDialogBridgeProps = Omit<
ComponentProps<typeof MemberDetailDialog>,
'leadActivity' | 'spawnEntry'
>;
type TeamSidebarRailBridgeProps = Omit<
ComponentProps<typeof TeamSidebarRail>,
'messagesPanelProps'
> & {
messagesPanelProps: SharedTeamMessagesPanelProps;
};
function buildMemberSpawnStatusMap(
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
): Map<string, MemberSpawnStatusEntry> | undefined {
if (!memberSpawnStatuses) {
return undefined;
}
const map = new Map<string, MemberSpawnStatusEntry>(Object.entries(memberSpawnStatuses));
return map.size > 0 ? map : undefined;
}
const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
teamName,
isTeamProvisioning,
isTeamAlive,
}: {
teamName: string;
isTeamProvisioning: boolean;
isTeamAlive?: boolean;
}): null {
const { leadActivity, memberSpawnStatuses, fetchMemberSpawnStatuses } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
}))
);
useEffect(() => {
const shouldFetchSpawnStatuses =
isTeamProvisioning ||
(memberSpawnStatuses == null &&
(isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle'));
if (shouldFetchSpawnStatuses) {
void fetchMemberSpawnStatuses(teamName);
}
}, [
fetchMemberSpawnStatuses,
isTeamAlive,
isTeamProvisioning,
leadActivity,
memberSpawnStatuses,
teamName,
]);
return null;
});
const TeamMemberListBridge = memo(function TeamMemberListBridge({
teamName,
...props
}: TeamMemberListBridgeProps): React.JSX.Element {
const { leadActivity, memberSpawnStatuses } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
}))
);
const memberSpawnStatusMap = useMemo(
() => buildMemberSpawnStatusMap(memberSpawnStatuses),
[memberSpawnStatuses]
);
return (
<MemberList {...props} leadActivity={leadActivity} memberSpawnStatuses={memberSpawnStatusMap} />
);
});
const TeamMessagesPanelBridge = memo(function TeamMessagesPanelBridge({
teamName,
...props
}: TeamMessagesPanelBridgeProps): React.JSX.Element {
const { leadActivity, leadContextUpdatedAt } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[teamName],
leadContextUpdatedAt: s.leadContextByTeam[teamName]?.updatedAt,
}))
);
return (
<MessagesPanel
{...props}
teamName={teamName}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
/>
);
});
const TeamSidebarRailBridge = memo(function TeamSidebarRailBridge({
messagesPanelProps,
...props
}: TeamSidebarRailBridgeProps): React.JSX.Element {
const { leadActivity, leadContextUpdatedAt } = useStore(
useShallow((s) => ({
leadActivity: s.leadActivityByTeam[messagesPanelProps.teamName],
leadContextUpdatedAt: s.leadContextByTeam[messagesPanelProps.teamName]?.updatedAt,
}))
);
const bridgedMessagesPanelProps = useMemo(
() => ({
...messagesPanelProps,
leadActivity,
leadContextUpdatedAt,
}),
[leadActivity, leadContextUpdatedAt, messagesPanelProps]
);
return <TeamSidebarRail {...props} messagesPanelProps={bridgedMessagesPanelProps} />;
});
const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge({
teamName,
member,
...props
}: TeamMemberDetailDialogBridgeProps): React.JSX.Element | null {
const leadActivity = useStore((s) => s.leadActivityByTeam[teamName]);
const spawnEntry = useStore((s) =>
member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined
);
return (
<MemberDetailDialog
{...props}
teamName={teamName}
member={member}
leadActivity={leadActivity}
spawnEntry={spawnEntry}
/>
);
});
export const TeamDetailView = ({ export const TeamDetailView = ({
teamName, teamName,
isPaneFocused = false, isPaneFocused = false,
@ -532,10 +687,6 @@ export const TeamDetailView = ({
provisioningError, provisioningError,
clearProvisioningError, clearProvisioningError,
isTeamProvisioning, isTeamProvisioning,
leadActivity,
leadContextUpdatedAt,
memberSpawnStatuses,
fetchMemberSpawnStatuses,
refreshTeamData, refreshTeamData,
kanbanFilterQuery, kanbanFilterQuery,
clearKanbanFilter, clearKanbanFilter,
@ -582,10 +733,6 @@ export const TeamDetailView = ({
data: s.selectedTeamName === teamName ? s.selectedTeamData : null, data: s.selectedTeamName === teamName ? s.selectedTeamData : null,
loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false,
error: s.selectedTeamName === teamName ? s.selectedTeamError : null, error: s.selectedTeamName === teamName ? s.selectedTeamError : null,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContextUpdatedAt: teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined,
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
refreshTeamData: s.refreshTeamData, refreshTeamData: s.refreshTeamData,
kanbanFilterQuery: s.kanbanFilterQuery, kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter, clearKanbanFilter: s.clearKanbanFilter,
@ -683,52 +830,6 @@ export const TeamDetailView = ({
} }
}, [isTeamProvisioning]); }, [isTeamProvisioning]);
// Fetch initial spawn statuses when provisioning starts
useEffect(() => {
const shouldFetchSpawnStatuses =
Boolean(teamName) &&
(isTeamProvisioning ||
(memberSpawnStatuses == null &&
(data?.isAlive === true || leadActivity === 'active' || leadActivity === 'idle')));
if (teamName && shouldFetchSpawnStatuses) {
void fetchMemberSpawnStatuses(teamName);
}
}, [
data?.isAlive,
fetchMemberSpawnStatuses,
isTeamProvisioning,
leadActivity,
memberSpawnStatuses,
teamName,
]);
// Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
const memberSpawnStatusMap = useMemo(() => {
if (!memberSpawnStatuses) return undefined;
const map = new Map<
string,
{
status: MemberSpawnStatusEntry['status'];
launchState: MemberSpawnStatusEntry['launchState'];
error?: string;
updatedAt: string;
runtimeAlive?: boolean;
livenessSource?: MemberSpawnStatusEntry['livenessSource'];
}
>();
for (const [name, entry] of Object.entries(memberSpawnStatuses)) {
map.set(name, {
status: entry.status,
launchState: entry.launchState,
error: entry.error,
updatedAt: entry.updatedAt,
runtimeAlive: entry.runtimeAlive,
livenessSource: entry.livenessSource,
});
}
return map.size > 0 ? map : undefined;
}, [memberSpawnStatuses]);
const [kanbanSearch, setKanbanSearch] = useState(''); const [kanbanSearch, setKanbanSearch] = useState('');
// Open editor overlay when a file reveal is requested (e.g. from chip click) // Open editor overlay when a file reveal is requested (e.g. from chip click)
@ -1373,7 +1474,7 @@ export const TeamDetailView = ({
})(); })();
}; };
const sharedMessagesPanelProps = useMemo( const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
() => ({ () => ({
teamName, teamName,
onTogglePosition: toggleMessagesPanelMode, onTogglePosition: toggleMessagesPanelMode,
@ -1381,8 +1482,6 @@ export const TeamDetailView = ({
tasks: data?.tasks ?? [], tasks: data?.tasks ?? [],
messages: data?.messages ?? [], messages: data?.messages ?? [],
isTeamAlive: data?.isAlive, isTeamAlive: data?.isAlive,
leadActivity,
leadContextUpdatedAt,
timeWindow, timeWindow,
teamSessionIds, teamSessionIds,
currentLeadSessionId: data?.config.leadSessionId, currentLeadSessionId: data?.config.leadSessionId,
@ -1407,8 +1506,6 @@ export const TeamDetailView = ({
handleRestartTeam, handleRestartTeam,
handleSelectMember, handleSelectMember,
handleTaskIdClick, handleTaskIdClick,
leadActivity,
leadContextUpdatedAt,
pendingRepliesByMember, pendingRepliesByMember,
teamName, teamName,
teamSessionIds, teamSessionIds,
@ -1425,6 +1522,15 @@ export const TeamDetailView = ({
); );
} }
const spawnStatusWatcher = (
<TeamSpawnStatusWatcher
teamName={teamName}
isTeamProvisioning={isTeamProvisioning}
isTeamAlive={data?.isAlive}
/>
);
const renderBody = (): React.JSX.Element => {
if ((loading && !data) || (data && data.teamName !== teamName)) { if ((loading && !data) || (data && data.teamName !== teamName)) {
return ( return (
<div className="size-full overflow-auto p-4"> <div className="size-full overflow-auto p-4">
@ -1456,8 +1562,8 @@ export const TeamDetailView = ({
<div className="max-w-md text-center"> <div className="max-w-md text-center">
<p className="text-sm font-medium text-text">Team not launched yet</p> <p className="text-sm font-medium text-text">Team not launched yet</p>
<p className="mt-2 text-xs text-text-secondary"> <p className="mt-2 text-xs text-text-secondary">
This is a draft team - <strong>{draftDisplayName}</strong> has been configured with{' '} This is a draft team - <strong>{draftDisplayName}</strong> has been configured
{draftMemberCount} member with {draftMemberCount} member
{draftMemberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI yet. {draftMemberCount === 1 ? '' : 's'} but hasn&apos;t been provisioned by CLI yet.
Click Launch to select a model and start the team. Click Launch to select a model and start the team.
</p> </p>
@ -1551,7 +1657,9 @@ export const TeamDetailView = ({
> >
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2"> <div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p> <p className="text-sm font-medium text-[var(--color-text)]">
Visible Context
</p>
<p className="text-[10px] text-[var(--color-text-muted)]"> <p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'} {leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p> </p>
@ -1589,7 +1697,7 @@ export const TeamDetailView = ({
isActive={isThisTabActive} isActive={isThisTabActive}
isFocused={isPaneFocused} isFocused={isPaneFocused}
> >
<TeamSidebarRail <TeamSidebarRailBridge
teamName={teamName} teamName={teamName}
messagesPanelProps={sharedMessagesPanelProps} messagesPanelProps={sharedMessagesPanelProps}
isResizing={isMessagesPanelResizing} isResizing={isMessagesPanelResizing}
@ -1870,15 +1978,14 @@ export const TeamDetailView = ({
</div> </div>
} }
> >
<MemberList <TeamMemberListBridge
teamName={teamName}
members={data.members} members={data.members}
memberTaskCounts={memberTaskCounts} memberTaskCounts={memberTaskCounts}
taskMap={taskMap} taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
memberSpawnStatuses={memberSpawnStatusMap}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
launchParams={launchParams} launchParams={launchParams}
onMemberClick={handleSelectMember} onMemberClick={handleSelectMember}
onSendMessage={handleSendMessageToMember} onSendMessage={handleSendMessageToMember}
@ -1958,7 +2065,10 @@ export const TeamDetailView = ({
onApprove={(taskId) => { onApprove={(taskId) => {
void (async () => { void (async () => {
try { try {
await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); await updateKanban(teamName, taskId, {
op: 'set_column',
column: 'approved',
});
} catch { } catch {
// error via store // error via store
} }
@ -2125,7 +2235,7 @@ export const TeamDetailView = ({
{messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />} {messagesPanelMode !== 'sidebar' && <ClaudeLogsSection teamName={teamName} />}
{messagesPanelMode === 'inline' && ( {messagesPanelMode === 'inline' && (
<MessagesPanel position="inline" {...sharedMessagesPanelProps} /> <TeamMessagesPanelBridge position="inline" {...sharedMessagesPanelProps} />
)} )}
<ReviewDialog <ReviewDialog
@ -2153,7 +2263,7 @@ export const TeamDetailView = ({
}} }}
/> />
<MemberDetailDialog <TeamMemberDetailDialogBridge
open={selectedMember !== null} open={selectedMember !== null}
member={selectedMember} member={selectedMember}
teamName={teamName} teamName={teamName}
@ -2161,8 +2271,6 @@ export const TeamDetailView = ({
messages={data.messages} messages={data.messages}
isTeamAlive={data.isAlive} isTeamAlive={data.isAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
leadActivity={leadActivity}
spawnEntry={selectedMember ? memberSpawnStatusMap?.get(selectedMember.name) : undefined}
onClose={() => setSelectedMember(null)} onClose={() => setSelectedMember(null)}
onSendMessage={() => { onSendMessage={() => {
const name = selectedMember?.name ?? ''; const name = selectedMember?.name ?? '';
@ -2285,8 +2393,8 @@ export const TeamDetailView = ({
<DialogHeader> <DialogHeader>
<DialogTitle>Remove member</DialogTitle> <DialogTitle>Remove member</DialogTitle>
<DialogDescription> <DialogDescription>
Remove &ldquo;{removeMemberConfirm}&rdquo; from the team? Tasks and messages will Remove &ldquo;{removeMemberConfirm}&rdquo; from the team? Tasks and messages
be preserved, but this name cannot be reused. will be preserved, but this name cannot be reused.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -2507,4 +2615,12 @@ export const TeamDetailView = ({
)} )}
</> </>
); );
};
return (
<>
{spawnStatusWatcher}
{renderBody()}
</>
);
}; };