perf(renderer): split noisy team detail subscriptions
This commit is contained in:
parent
0656794026
commit
affd9ac748
1 changed files with 1174 additions and 1058 deletions
|
|
@ -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't been provisioned by CLI yet.
|
{draftMemberCount === 1 ? '' : 's'} but hasn'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 “{removeMemberConfirm}” from the team? Tasks and messages will
|
Remove “{removeMemberConfirm}” 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()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue