feat(team): enhance team messaging functionality and UI
- Integrated pending replies state management for team members. - Updated TeamDetailView to initialize pending replies from state. - Added logic to refresh team messages and member activity on tab focus. - Improved UI components by increasing dialog content width for better layout. - Enhanced member draft rows with avatar support for better visual representation. - Implemented reconciliation logic for pending replies based on message history. - Updated tests to cover new functionality and ensure reliability.
This commit is contained in:
parent
1d3d7e1f1f
commit
52677b55d0
12 changed files with 365 additions and 38 deletions
|
|
@ -107,6 +107,10 @@ import { ScheduleSection } from './schedule/ScheduleSection';
|
||||||
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
import { TeamSidebarHost } from './sidebar/TeamSidebarHost';
|
||||||
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
import { TeamSidebarPortalSource } from './sidebar/TeamSidebarPortalSource';
|
||||||
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||||||
|
import {
|
||||||
|
getTeamPendingRepliesState,
|
||||||
|
setTeamPendingRepliesState,
|
||||||
|
} from './sidebar/teamSidebarUiState';
|
||||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
|
|
@ -914,7 +918,9 @@ export const TeamDetailView = ({
|
||||||
initialTab?: MemberDetailTab;
|
initialTab?: MemberDetailTab;
|
||||||
initialActivityFilter?: MemberActivityFilter;
|
initialActivityFilter?: MemberActivityFilter;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>(() =>
|
||||||
|
getTeamPendingRepliesState(teamName)
|
||||||
|
);
|
||||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
defaultSubject: '',
|
defaultSubject: '',
|
||||||
|
|
@ -1211,6 +1217,8 @@ export const TeamDetailView = ({
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
|
refreshMemberActivityMeta,
|
||||||
syncTeamPendingReplyRefresh,
|
syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
|
@ -1262,6 +1270,8 @@ export const TeamDetailView = ({
|
||||||
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,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
|
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
|
||||||
|
refreshMemberActivityMeta: s.refreshMemberActivityMeta,
|
||||||
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
|
|
@ -1285,6 +1295,7 @@ export const TeamDetailView = ({
|
||||||
const tabId = useTabIdOptional();
|
const tabId = useTabIdOptional();
|
||||||
const activeTabId = useStore((s) => s.activeTabId);
|
const activeTabId = useStore((s) => s.activeTabId);
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
|
const wasInteractiveRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -1348,6 +1359,14 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [tabId, initTabUIState]);
|
}, [tabId, initTabUIState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingRepliesByMember(getTeamPendingRepliesState(teamName));
|
||||||
|
}, [teamName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTeamPendingRepliesState(teamName, pendingRepliesByMember);
|
||||||
|
}, [pendingRepliesByMember, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wasProvisioning = wasProvisioningRef.current;
|
const wasProvisioning = wasProvisioningRef.current;
|
||||||
wasProvisioningRef.current = isTeamProvisioning;
|
wasProvisioningRef.current = isTeamProvisioning;
|
||||||
|
|
@ -1386,6 +1405,32 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
|
}, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isInteractive = isThisTabActive && isPaneFocused;
|
||||||
|
const justBecameInteractive = isInteractive && !wasInteractiveRef.current;
|
||||||
|
wasInteractiveRef.current = isInteractive;
|
||||||
|
if (!justBecameInteractive || !teamName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const headResult = await refreshTeamMessagesHead(teamName);
|
||||||
|
if (headResult.feedChanged) {
|
||||||
|
await refreshMemberActivityMeta(teamName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort refresh on tab focus.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [
|
||||||
|
isPaneFocused,
|
||||||
|
isThisTabActive,
|
||||||
|
refreshMemberActivityMeta,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
// Fetch active teams when launch dialog opens (for conflict warning)
|
// Fetch active teams when launch dialog opens (for conflict warning)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!launchDialogOpen) return;
|
if (!launchDialogOpen) return;
|
||||||
|
|
|
||||||
|
|
@ -1220,7 +1220,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
||||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { buildMemberColorMap, displayMemberName } from '@renderer/utils/memberHelpers';
|
import {
|
||||||
|
agentAvatarUrl,
|
||||||
|
buildMemberColorMap,
|
||||||
|
displayMemberName,
|
||||||
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -451,7 +455,7 @@ export const EditTeamDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Team</DialogTitle>
|
<DialogTitle>Edit Team</DialogTitle>
|
||||||
<DialogDescription>Change team name, description and color</DialogDescription>
|
<DialogDescription>Change team name, description and color</DialogDescription>
|
||||||
|
|
@ -518,6 +522,7 @@ export const EditTeamDialog = ({
|
||||||
<MemberDraftRow
|
<MemberDraftRow
|
||||||
member={leadDraft}
|
member={leadDraft}
|
||||||
index={0}
|
index={0}
|
||||||
|
avatarSrc={agentAvatarUrl('team-lead', 32)}
|
||||||
resolvedColor={effectiveResolvedMemberColorMap.get(
|
resolvedColor={effectiveResolvedMemberColorMap.get(
|
||||||
leadDraft.originalName ?? leadDraft.name
|
leadDraft.originalName ?? leadDraft.name
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1560,7 +1560,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={isSchedule ? 'max-h-[90vh] max-w-2xl overflow-y-auto' : 'max-w-2xl'}
|
className={isSchedule ? 'max-h-[90vh] max-w-3xl overflow-y-auto' : 'max-w-3xl'}
|
||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
|
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ interface SendMessageDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky action mode — survives dialog close/reopen (component remount)
|
// Sticky action mode within the current session.
|
||||||
// Default: 'delegate' for teams (overridden to 'do' if solo/no teammates)
|
// Each dialog open still re-derives the default from the current team shape.
|
||||||
let stickyActionMode: ActionMode = 'delegate';
|
let stickyActionMode: ActionMode = 'delegate';
|
||||||
|
|
||||||
export const SendMessageDialog = ({
|
export const SendMessageDialog = ({
|
||||||
|
|
@ -168,7 +168,12 @@ export const SendMessageDialog = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !prevOpenRef.current) {
|
if (open && !prevOpenRef.current) {
|
||||||
const leadName = members.find((m) => isLeadMember(m))?.name;
|
const leadName = members.find((m) => isLeadMember(m))?.name;
|
||||||
setMember(defaultRecipient ?? leadName ?? '');
|
const nextRecipient = defaultRecipient ?? leadName ?? '';
|
||||||
|
const nextRecipientMember = members.find((candidate) => candidate.name === nextRecipient);
|
||||||
|
const nextCanDelegate =
|
||||||
|
members.length > 1 && Boolean(nextRecipientMember && isLeadMember(nextRecipientMember));
|
||||||
|
setMember(nextRecipient);
|
||||||
|
setActionMode(nextCanDelegate ? 'delegate' : 'do');
|
||||||
setQuote(quotedMessage);
|
setQuote(quotedMessage);
|
||||||
setQuoteExpanded(false);
|
setQuoteExpanded(false);
|
||||||
prevResultRef.current = lastResult;
|
prevResultRef.current = lastResult;
|
||||||
|
|
@ -188,6 +193,8 @@ export const SendMessageDialog = ({
|
||||||
defaultChip,
|
defaultChip,
|
||||||
quotedMessage,
|
quotedMessage,
|
||||||
lastResult,
|
lastResult,
|
||||||
|
members,
|
||||||
|
setActionMode,
|
||||||
textDraft,
|
textDraft,
|
||||||
chipDraft,
|
chipDraft,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Label } from '@renderer/components/ui/label';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
import { isAnthropicHaikuTeamModel } from '@renderer/utils/teamModelCatalog';
|
||||||
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
import { resolveTeamLeadColorName } from '@shared/utils/teamMemberColors';
|
||||||
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
@ -63,7 +64,7 @@ export const LeadModelRow = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[auto_1fr_auto]"
|
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[minmax(0,1fr)_auto_auto]"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isLight
|
backgroundColor: isLight
|
||||||
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
? 'color-mix(in srgb, var(--color-surface-raised) 22%, white 78%)'
|
||||||
|
|
@ -77,9 +78,17 @@ export const LeadModelRow = ({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex h-8 items-center gap-3 px-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-[var(--color-text)]">lead</span>
|
<img
|
||||||
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
|
src={agentAvatarUrl('team-lead', 32)}
|
||||||
|
alt=""
|
||||||
|
className="size-8 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="flex h-8 min-w-0 items-center gap-3">
|
||||||
|
<span className="truncate text-sm font-medium text-[var(--color-text)]">lead</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--color-text-secondary)]">Team Lead</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
interface MemberDraftRowProps {
|
interface MemberDraftRowProps {
|
||||||
member: MemberDraft;
|
member: MemberDraft;
|
||||||
index: number;
|
index: number;
|
||||||
|
avatarSrc?: string;
|
||||||
resolvedColor?: string;
|
resolvedColor?: string;
|
||||||
nameError: string | null;
|
nameError: string | null;
|
||||||
onNameChange: (id: string, name: string) => void;
|
onNameChange: (id: string, name: string) => void;
|
||||||
|
|
@ -74,6 +75,7 @@ interface MemberDraftRowProps {
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
member,
|
member,
|
||||||
index,
|
index,
|
||||||
|
avatarSrc,
|
||||||
resolvedColor,
|
resolvedColor,
|
||||||
nameError,
|
nameError,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
|
|
@ -221,14 +223,24 @@ export const MemberDraftRow = ({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
className="h-8 text-xs"
|
{avatarSrc ? (
|
||||||
value={member.name}
|
<img
|
||||||
aria-label={`Member ${index + 1} name`}
|
src={avatarSrc}
|
||||||
disabled={isRemoved || lockIdentity}
|
alt=""
|
||||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
className="size-8 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
placeholder="member-name"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={member.name}
|
||||||
|
aria-label={`Member ${index + 1} name`}
|
||||||
|
disabled={isRemoved || lockIdentity}
|
||||||
|
onChange={(event) => onNameChange(member.id, event.target.value)}
|
||||||
|
placeholder="member-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
@ -315,6 +316,7 @@ export const MembersEditorSection = ({
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
index={index}
|
index={index}
|
||||||
|
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
|
||||||
resolvedColor={memberColorMap.get(member.id)}
|
resolvedColor={memberColorMap.get(member.id)}
|
||||||
nameError={validateMemberName?.(member.name) ?? null}
|
nameError={validateMemberName?.(member.name) ?? null}
|
||||||
onNameChange={updateMemberName}
|
onNameChange={updateMemberName}
|
||||||
|
|
@ -356,6 +358,7 @@ export const MembersEditorSection = ({
|
||||||
key={member.id}
|
key={member.id}
|
||||||
member={member}
|
member={member}
|
||||||
index={activeMembers.length + index}
|
index={activeMembers.length + index}
|
||||||
|
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
|
||||||
resolvedColor={memberColorMap.get(member.id)}
|
resolvedColor={memberColorMap.get(member.id)}
|
||||||
nameError={null}
|
nameError={null}
|
||||||
onNameChange={updateMemberName}
|
onNameChange={updateMemberName}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,9 @@ export const MessageComposer = ({
|
||||||
if (!isInitializedRef.current) {
|
if (!isInitializedRef.current) {
|
||||||
isInitializedRef.current = true;
|
isInitializedRef.current = true;
|
||||||
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
prevShouldAutoDelegateRef.current = shouldAutoDelegate;
|
||||||
|
if (shouldAutoDelegate && actionMode === 'do') {
|
||||||
|
setActionMode('delegate');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,60 @@ interface MessagesPanelProps {
|
||||||
onTaskIdClick?: (taskId: string) => void;
|
onTaskIdClick?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reconcilePendingRepliesByMember(
|
||||||
|
pendingRepliesByMember: Record<string, number>,
|
||||||
|
messages: InboxMessage[]
|
||||||
|
): Record<string, number> {
|
||||||
|
if (Object.keys(pendingRepliesByMember).length === 0) {
|
||||||
|
return pendingRepliesByMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestUserSentByMember = new Map<string, number>();
|
||||||
|
const latestReplyToUserByMember = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
const ts = Date.parse(message.timestamp);
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
message.from === 'user' &&
|
||||||
|
typeof message.to === 'string' &&
|
||||||
|
message.to.length > 0 &&
|
||||||
|
message.source === 'user_sent'
|
||||||
|
) {
|
||||||
|
const previous = latestUserSentByMember.get(message.to);
|
||||||
|
if (previous == null || ts > previous) {
|
||||||
|
latestUserSentByMember.set(message.to, ts);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.to === 'user') {
|
||||||
|
const previous = latestReplyToUserByMember.get(message.from);
|
||||||
|
if (previous == null || ts > previous) {
|
||||||
|
latestReplyToUserByMember.set(message.from, ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||||
|
const latestReplyAt = latestReplyToUserByMember.get(memberName);
|
||||||
|
const latestDurableSendAt = latestUserSentByMember.get(memberName);
|
||||||
|
const threshold = latestDurableSendAt ?? sentAtMs;
|
||||||
|
if (latestReplyAt != null && latestReplyAt > threshold) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next[memberName] = sentAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : pendingRepliesByMember;
|
||||||
|
}
|
||||||
|
|
||||||
export const MessagesPanel = memo(function MessagesPanel({
|
export const MessagesPanel = memo(function MessagesPanel({
|
||||||
teamName,
|
teamName,
|
||||||
position,
|
position,
|
||||||
|
|
@ -125,6 +179,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
messages,
|
messages,
|
||||||
messagesState,
|
messagesState,
|
||||||
loadOlderTeamMessages,
|
loadOlderTeamMessages,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
sendTeamMessage: s.sendTeamMessage,
|
sendTeamMessage: s.sendTeamMessage,
|
||||||
|
|
@ -137,8 +192,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
messages: selectTeamMessages(s, teamName),
|
messages: selectTeamMessages(s, teamName),
|
||||||
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
messagesState: teamName ? s.teamMessagesByName[teamName] : undefined,
|
||||||
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
loadOlderTeamMessages: s.loadOlderTeamMessages,
|
||||||
|
refreshTeamMessagesHead: s.refreshTeamMessagesHead,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
const bootstrapHeadRefreshAttemptedForTeamRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const loadOlderMessages = useCallback(async () => {
|
const loadOlderMessages = useCallback(async () => {
|
||||||
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
if (!messagesState?.hasMore || messagesState.loadingHead || messagesState.loadingOlder) {
|
||||||
|
|
@ -224,6 +281,37 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
bottomSheetSnapIndex,
|
bottomSheetSnapIndex,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesSearchBarVisible || messagesSearchQuery.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessagesSearchBarVisible(true);
|
||||||
|
}, [messagesSearchBarVisible, messagesSearchQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!teamName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (effectiveMessages.length > 0) {
|
||||||
|
bootstrapHeadRefreshAttemptedForTeamRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messagesState?.loadingHead || messagesState?.loadingOlder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bootstrapHeadRefreshAttemptedForTeamRef.current === teamName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bootstrapHeadRefreshAttemptedForTeamRef.current = teamName;
|
||||||
|
void refreshTeamMessagesHead(teamName).catch(() => undefined);
|
||||||
|
}, [
|
||||||
|
effectiveMessages.length,
|
||||||
|
messagesState?.loadingHead,
|
||||||
|
messagesState?.loadingOlder,
|
||||||
|
refreshTeamMessagesHead,
|
||||||
|
teamName,
|
||||||
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (position !== 'sidebar') return;
|
if (position !== 'sidebar') return;
|
||||||
const el = sidebarScrollRef.current;
|
const el = sidebarScrollRef.current;
|
||||||
|
|
@ -352,20 +440,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
// Auto-clear pending replies when a member actually responds
|
// Auto-clear pending replies when a member actually responds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
if (Object.keys(pendingRepliesByMember).length === 0) return;
|
||||||
const next = { ...pendingRepliesByMember };
|
const next = reconcilePendingRepliesByMember(pendingRepliesByMember, replyCandidateMessages);
|
||||||
let changed = false;
|
if (next !== pendingRepliesByMember) onPendingReplyChange(() => next);
|
||||||
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
|
||||||
const hasReply = replyCandidateMessages.some((m) => {
|
|
||||||
if (m.from !== memberName) return false;
|
|
||||||
const ts = Date.parse(m.timestamp);
|
|
||||||
return Number.isFinite(ts) && ts > sentAtMs;
|
|
||||||
});
|
|
||||||
if (hasReply) {
|
|
||||||
delete next[memberName];
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) onPendingReplyChange(() => next);
|
|
||||||
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export interface TeamClaudeLogsSidebarUiState {
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesStateByTeam = new Map<string, TeamMessagesSidebarUiState>();
|
const messagesStateByTeam = new Map<string, TeamMessagesSidebarUiState>();
|
||||||
|
const pendingRepliesStateByTeam = new Map<string, Record<string, number>>();
|
||||||
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
|
const claudeLogsStateByTeam = new Map<string, TeamClaudeLogsSidebarUiState>();
|
||||||
|
|
||||||
function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState {
|
function cloneMessagesFilter(filter: MessagesFilterState): MessagesFilterState {
|
||||||
|
|
@ -101,6 +102,17 @@ export function setTeamMessagesSidebarUiState(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTeamPendingRepliesState(teamName: string): Record<string, number> {
|
||||||
|
return { ...(pendingRepliesStateByTeam.get(teamName) ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTeamPendingRepliesState(
|
||||||
|
teamName: string,
|
||||||
|
pendingRepliesByMember: Record<string, number>
|
||||||
|
): void {
|
||||||
|
pendingRepliesStateByTeam.set(teamName, { ...pendingRepliesByMember });
|
||||||
|
}
|
||||||
|
|
||||||
export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState {
|
export function getTeamClaudeLogsSidebarUiState(teamName: string): TeamClaudeLogsSidebarUiState {
|
||||||
const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState();
|
const state = claudeLogsStateByTeam.get(teamName) ?? createDefaultClaudeLogsSidebarUiState();
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ const storeState = {
|
||||||
teams: [],
|
teams: [],
|
||||||
openTeamTab: vi.fn(),
|
openTeamTab: vi.fn(),
|
||||||
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
||||||
|
refreshTeamMessagesHead: vi.fn().mockResolvedValue({
|
||||||
|
feedChanged: true,
|
||||||
|
headChanged: true,
|
||||||
|
feedRevision: 'rev-1',
|
||||||
|
}),
|
||||||
teamMessagesByName: {} as Record<
|
teamMessagesByName: {} as Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +45,17 @@ const expandedHookState = {
|
||||||
toggle: vi.fn(),
|
toggle: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sidebarUiState = {
|
||||||
|
messagesSearchQuery: '',
|
||||||
|
messagesFilter: { from: new Set<string>(), to: new Set<string>(), showNoise: false },
|
||||||
|
messagesFilterOpen: false,
|
||||||
|
messagesCollapsed: true,
|
||||||
|
messagesSearchBarVisible: false,
|
||||||
|
expandedItemKey: null as string | null,
|
||||||
|
messagesScrollTop: 0,
|
||||||
|
bottomSheetSnapIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('@renderer/store', () => ({
|
vi.mock('@renderer/store', () => ({
|
||||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||||
}));
|
}));
|
||||||
|
|
@ -95,6 +111,24 @@ vi.mock('@renderer/components/team/messages/StatusBlock', () => ({
|
||||||
StatusBlock: () => React.createElement('div', null, 'status-block'),
|
StatusBlock: () => React.createElement('div', null, 'status-block'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@renderer/components/team/sidebar/teamSidebarUiState', () => ({
|
||||||
|
getTeamMessagesSidebarUiState: () => ({
|
||||||
|
messagesSearchQuery: sidebarUiState.messagesSearchQuery,
|
||||||
|
messagesFilter: {
|
||||||
|
from: new Set(sidebarUiState.messagesFilter.from),
|
||||||
|
to: new Set(sidebarUiState.messagesFilter.to),
|
||||||
|
showNoise: sidebarUiState.messagesFilter.showNoise,
|
||||||
|
},
|
||||||
|
messagesFilterOpen: sidebarUiState.messagesFilterOpen,
|
||||||
|
messagesCollapsed: sidebarUiState.messagesCollapsed,
|
||||||
|
messagesSearchBarVisible: sidebarUiState.messagesSearchBarVisible,
|
||||||
|
expandedItemKey: sidebarUiState.expandedItemKey,
|
||||||
|
messagesScrollTop: sidebarUiState.messagesScrollTop,
|
||||||
|
bottomSheetSnapIndex: sidebarUiState.bottomSheetSnapIndex,
|
||||||
|
}),
|
||||||
|
setTeamMessagesSidebarUiState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({
|
vi.mock('@renderer/components/team/activity/ActivityTimeline', () => ({
|
||||||
ActivityTimeline: ({ messages }: { messages: InboxMessage[] }) =>
|
ActivityTimeline: ({ messages }: { messages: InboxMessage[] }) =>
|
||||||
React.createElement(
|
React.createElement(
|
||||||
|
|
@ -132,7 +166,10 @@ vi.mock('react-modal-sheet', () => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { MessagesPanel } from '@renderer/components/team/messages/MessagesPanel';
|
import {
|
||||||
|
MessagesPanel,
|
||||||
|
reconcilePendingRepliesByMember,
|
||||||
|
} from '@renderer/components/team/messages/MessagesPanel';
|
||||||
|
|
||||||
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||||
return {
|
return {
|
||||||
|
|
@ -162,7 +199,16 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
storeState.sendCrossTeamMessage.mockClear();
|
storeState.sendCrossTeamMessage.mockClear();
|
||||||
storeState.openTeamTab.mockClear();
|
storeState.openTeamTab.mockClear();
|
||||||
storeState.loadOlderTeamMessages.mockClear();
|
storeState.loadOlderTeamMessages.mockClear();
|
||||||
|
storeState.refreshTeamMessagesHead.mockClear();
|
||||||
storeState.teamMessagesByName = {};
|
storeState.teamMessagesByName = {};
|
||||||
|
sidebarUiState.messagesSearchQuery = '';
|
||||||
|
sidebarUiState.messagesFilter = { from: new Set(), to: new Set(), showNoise: false };
|
||||||
|
sidebarUiState.messagesFilterOpen = false;
|
||||||
|
sidebarUiState.messagesCollapsed = true;
|
||||||
|
sidebarUiState.messagesSearchBarVisible = false;
|
||||||
|
sidebarUiState.expandedItemKey = null;
|
||||||
|
sidebarUiState.messagesScrollTop = 0;
|
||||||
|
sidebarUiState.bottomSheetSnapIndex = 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
|
it('keeps read passive peer summaries in the activity timeline while unread badge only counts filtered unread messages', async () => {
|
||||||
|
|
@ -286,7 +332,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears pending replies when a real member reply arrives after the pending timestamp', async () => {
|
it('clears pending replies when a real member reply to the user arrives after the pending timestamp', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -296,10 +342,11 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z');
|
const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z');
|
||||||
const messages: InboxMessage[] = [
|
const messages: InboxMessage[] = [
|
||||||
makeMessage({
|
makeMessage({
|
||||||
messageId: 'lead-reply',
|
messageId: 'member-reply',
|
||||||
from: 'alice',
|
from: 'alice',
|
||||||
|
to: 'user',
|
||||||
read: true,
|
read: true,
|
||||||
source: 'lead_process',
|
source: 'inbox',
|
||||||
timestamp: '2026-04-08T12:01:00.000Z',
|
timestamp: '2026-04-08T12:01:00.000Z',
|
||||||
text: 'Starting now.',
|
text: 'Starting now.',
|
||||||
}),
|
}),
|
||||||
|
|
@ -344,6 +391,30 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears pending replies from durable user_sent history even if the local pending timestamp drifted later', () => {
|
||||||
|
const pendingSentAtMs = Date.parse('2026-04-08T12:02:00.000Z');
|
||||||
|
const messages: InboxMessage[] = [
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'user-send',
|
||||||
|
from: 'user',
|
||||||
|
to: 'forge',
|
||||||
|
source: 'user_sent',
|
||||||
|
timestamp: '2026-04-08T12:00:00.000Z',
|
||||||
|
text: 'Тут?',
|
||||||
|
}),
|
||||||
|
makeMessage({
|
||||||
|
messageId: 'forge-reply',
|
||||||
|
from: 'forge',
|
||||||
|
to: 'user',
|
||||||
|
source: 'inbox',
|
||||||
|
timestamp: '2026-04-08T12:00:05.000Z',
|
||||||
|
text: 'Да, я тут.',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(reconcilePendingRepliesByMember({ forge: pendingSentAtMs }, messages)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => {
|
it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
@ -389,4 +460,88 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reopens the search bar when a persisted search query is active', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
sidebarUiState.messagesSearchQuery = 'Тут?';
|
||||||
|
sidebarUiState.messagesSearchBarVisible = false;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
storeState.teamMessagesByName['atlas-hq'] = {
|
||||||
|
canonicalMessages: [makeMessage({ text: 'Тут?' })],
|
||||||
|
optimisticMessages: [],
|
||||||
|
feedRevision: 'rev-1',
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
lastFetchedAt: Date.now(),
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
headHydrated: true,
|
||||||
|
};
|
||||||
|
root.render(
|
||||||
|
React.createElement(MessagesPanel, {
|
||||||
|
teamName: 'atlas-hq',
|
||||||
|
position: 'sidebar',
|
||||||
|
onPositionChange: vi.fn(),
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
timeWindow: null,
|
||||||
|
pendingRepliesByMember: {},
|
||||||
|
onPendingReplyChange: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('input[placeholder=\"Search...\"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests a one-shot head refresh when the messages cache is empty', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
storeState.teamMessagesByName['atlas-hq'] = {
|
||||||
|
canonicalMessages: [],
|
||||||
|
optimisticMessages: [],
|
||||||
|
feedRevision: null,
|
||||||
|
nextCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
lastFetchedAt: null,
|
||||||
|
loadingHead: false,
|
||||||
|
loadingOlder: false,
|
||||||
|
headHydrated: false,
|
||||||
|
};
|
||||||
|
root.render(
|
||||||
|
React.createElement(MessagesPanel, {
|
||||||
|
teamName: 'atlas-hq',
|
||||||
|
position: 'sidebar',
|
||||||
|
onPositionChange: vi.fn(),
|
||||||
|
members: [],
|
||||||
|
tasks: [],
|
||||||
|
timeWindow: null,
|
||||||
|
pendingRepliesByMember: {},
|
||||||
|
onPendingReplyChange: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(storeState.refreshTeamMessagesHead).toHaveBeenCalledWith('atlas-hq');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue