agent-ecosystem/src/renderer/components/team/activity/MessageExpandDialog.tsx
infiniti 9758fafd10
feat(team): support sent message revisions
* fix(opencode): recover empty bridge output sends

* fix(opencode): handle empty readiness bridge output

* fix(opencode): retry read-only bridge no-output

* fix(opencode): recover empty bridge output sends

---------

Co-authored-by: iliya <iliyazelenkog@gmail.com>

* fix(runtime-provider): clarify opencode model routes ux

* fix(team): show create dialog loading fallback

* feat(team): support revising sent messages

* test(team): cover sent message revision flow

* fix(opencode): harden local runtime bridge support

* fix(member-work-sync): recover stale nudge payload conflicts

* fix(runtime): gate codex install prompt on runtime status

* fix(team): persist incomplete launch state before cleanup

* fix(team): keep launch pending for dead runtime entries

* feat(logs): compact team log source controls

* fix(build): verify radix presence patch before build

* fix(renderer): recover failed dynamic imports

* fix(team): preserve task labels and change presence

* fix(logs): restore member process log source

* fix(team): require revision notice before editing

---------

Co-authored-by: iliya <iliyazelenkog@gmail.com>
Co-authored-by: 777genius <quantjumppro@gmail.com>
2026-05-25 21:11:59 +03:00

224 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useCallback, useMemo, useRef } from 'react';
import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { agentAvatarUrl, buildMemberAvatarMap } from '@renderer/utils/memberHelpers';
import { MemberBadge } from '../MemberBadge';
import { ActivityItem } from './ActivityItem';
import { buildMessageContext, resolveMessageRenderProps } from './activityMessageContext';
import { ThoughtBodyContent } from './ThoughtBodyContent';
import type { LeadThoughtGroup, TimelineItem } from './LeadThoughtsGroup';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
function formatTime(timestamp: string): string {
const d = new Date(timestamp);
if (Number.isNaN(d.getTime())) return timestamp;
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
interface DialogThoughtsContentProps {
group: LeadThoughtGroup;
members?: ResolvedTeamMember[];
memberColor?: string;
onTaskIdClick?: (taskId: string) => void;
onReply?: (message: InboxMessage) => void;
memberColorMap?: Map<string, string>;
teamNames?: string[];
teamColorByName?: ReadonlyMap<string, string>;
onTeamClick?: (teamName: string) => void;
}
const DialogThoughtsContent = ({
group,
members,
memberColor,
onTaskIdClick,
onReply,
memberColorMap,
teamNames = [],
teamColorByName,
onTeamClick,
}: DialogThoughtsContentProps): React.JSX.Element => {
const { t } = useAppTranslation('team');
const { thoughts } = group;
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
const colors = getTeamColorSet(memberColor ?? '');
const avatarMap = useMemo(() => buildMemberAvatarMap(members ?? []), [members]);
const chronological = useMemo(() => [...thoughts].reverse(), [thoughts]);
return (
<div>
{/* Header */}
<div className="flex items-center gap-2 pb-3">
<img
src={avatarMap.get(newest.from) ?? agentAvatarUrl(newest.from, 32)}
alt=""
className="size-6 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<MemberBadge name={newest.from} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{t('activity.thoughts.count', { count: thoughts.length })}
</span>
<span className="ml-auto text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
? formatTime(oldest.timestamp)
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`}
</span>
</div>
{/* Body */}
<div
className="rounded-md"
style={{
border: `1px solid var(--color-border-subtle)`,
borderLeft: `3px solid ${colors.border}`,
}}
>
{chronological.map((thought, idx) => (
<ThoughtBodyContent
key={thought.messageId ?? idx}
thought={thought}
showDivider={idx > 0}
onTaskIdClick={onTaskIdClick}
onReply={onReply}
memberColorMap={memberColorMap}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
))}
</div>
</div>
);
};
interface MessageExpandDialogProps {
expandedItem: TimelineItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
teamName: string;
members?: ResolvedTeamMember[];
onCreateTaskFromMessage?: (subject: string, description: string) => void;
onReplyToMessage?: (message: InboxMessage) => void;
revisionMessageId?: string | null;
onReviseMessage?: (message: InboxMessage) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskIdClick?: (taskId: string) => void;
onRestartTeam?: () => void;
teamNames?: string[];
teamColorByName?: ReadonlyMap<string, string>;
onTeamClick?: (teamName: string) => void;
}
export const MessageExpandDialog = memo(function MessageExpandDialog({
expandedItem,
open,
onOpenChange,
teamName,
members,
onCreateTaskFromMessage,
onReplyToMessage,
revisionMessageId,
onReviseMessage,
onMemberClick,
onTaskIdClick,
onRestartTeam,
teamNames = [],
teamColorByName,
onTeamClick,
}: MessageExpandDialogProps): React.JSX.Element {
const { t } = useAppTranslation('team');
// Keep last valid item for exit animation
const lastItemRef = useRef<TimelineItem | null>(null);
if (expandedItem) lastItemRef.current = expandedItem;
const displayItem = expandedItem ?? lastItemRef.current;
const ctx = useMemo(() => buildMessageContext(members), [members]);
const handleMemberNameClick = useCallback(
(name: string) => {
const member = members?.find(
(candidate) => candidate.name === name || candidate.agentType === name
);
if (member) onMemberClick?.(member);
},
[members, onMemberClick]
);
const renderProps =
displayItem?.type === 'message' ? resolveMessageRenderProps(displayItem.message, ctx) : null;
const thoughtMemberColor =
displayItem?.type === 'lead-thoughts'
? ctx.memberInfo.get(displayItem.group.thoughts[0].from)?.color
: undefined;
const headerTitle =
displayItem?.type === 'message'
? displayItem.message.from
: displayItem?.type === 'lead-thoughts'
? t('activity.thoughts.titleForMember', { name: displayItem.group.thoughts[0].from })
: '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] w-[80vw] max-w-[2000px] flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 px-4 pt-4">
<DialogTitle className="text-sm">{headerTitle}</DialogTitle>
<DialogDescription className="sr-only">
{t('activity.expandDialog.description')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4">
{displayItem?.type === 'message' ? (
<ActivityItem
message={displayItem.message}
teamName={teamName}
memberRole={renderProps?.memberRole}
memberColor={renderProps?.memberColor}
recipientColor={renderProps?.recipientColor}
memberColorMap={ctx.colorMap}
localMemberNames={ctx.localMemberNames}
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
onCreateTask={onCreateTaskFromMessage}
onReply={onReplyToMessage}
canRevise={displayItem.message.messageId === revisionMessageId}
onRevise={onReviseMessage}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
compactHeader={false}
isCollapsed={false}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
) : displayItem?.type === 'lead-thoughts' ? (
<DialogThoughtsContent
group={displayItem.group}
members={members}
memberColor={thoughtMemberColor}
onTaskIdClick={onTaskIdClick}
onReply={onReplyToMessage}
memberColorMap={ctx.colorMap}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
) : null}
</div>
</DialogContent>
</Dialog>
);
});