From 674c1d8b13164e7007206b2972a3c511645bc6d4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 13 Apr 2026 10:41:23 +0300 Subject: [PATCH] feat(ui): polish messages timeline and graph toolbar --- .../team/activity/ActiveTasksBlock.tsx | 41 +- .../components/team/activity/ActivityItem.tsx | 446 ++++++++++-------- .../team/activity/LeadThoughtsGroup.tsx | 242 ++++++---- .../team/activity/PendingRepliesBlock.tsx | 12 +- .../team/messages/MessageComposer.tsx | 2 +- .../team/messages/MessagesPanel.tsx | 2 + .../components/team/messages/StatusBlock.tsx | 41 +- .../components/ui/MentionableTextarea.tsx | 10 +- .../agent-graph/ui/TeamGraphOverlay.tsx | 2 +- .../features/agent-graph/ui/TeamGraphTab.tsx | 2 +- src/renderer/index.css | 74 ++- 11 files changed, 535 insertions(+), 339 deletions(-) diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index b9c722cc..a90f12ca 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type ReactNode } from 'react'; import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; @@ -19,6 +19,7 @@ interface ActiveTasksBlockProps { tasks: TeamTaskWithKanban[]; /** Start collapsed (e.g. when rendered inside the sidebar where MemberList already shows status). */ defaultCollapsed?: boolean; + headerRight?: ReactNode; onMemberClick?: (member: ResolvedTeamMember) => void; onTaskClick?: (task: TeamTaskWithKanban) => void; } @@ -34,6 +35,7 @@ export const ActiveTasksBlock = ({ members, tasks, defaultCollapsed = false, + headerRight, onMemberClick, onTaskClick, }: ActiveTasksBlockProps): React.JSX.Element | null => { @@ -70,23 +72,26 @@ export const ActiveTasksBlock = ({ return (
- +
+ + {headerRight ?
{headerRight}
: null} +
{!collapsed && entries.map(({ member, task, taskId, kind }) => { const colors = getTeamColorSet(colorMap.get(member.name) ?? ''); diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2b80003f..23d2e014 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -881,6 +881,132 @@ export const ActivityItem = memo( onToggleCollapse?.(collapseToggleKey); } }, [collapseToggleKey, isHeaderClickable, onToggleCollapse]); + const useCompactCollapsedHeader = compactHeader && !isExpanded; + + const senderBadge = isSlashCommandResult ? ( + + result + + ) : ( + + ); + + const messageTypeBadge = systemLabel ? ( + + {systemLabel} + + ) : isSlashCommandResult && message.commandOutput ? ( + + {message.commandOutput.stream} + + ) : isSlashCommandMessage ? ( + command + ) : messageType ? ( + + {messageType} + + ) : null; + + const leadSourceBadge = + message.source === 'lead_session' && !isSlashCommandResult ? ( + + session + + ) : message.source === 'lead_process' && !isSlashCommandResult ? ( + + live + + ) : null; + + const statusBadge = rateLimited ? ( + + + Rate Limited + + ) : isApiError ? ( + + + API Error + + ) : null; + + const recipientBadge = + message.to && message.to !== message.from ? ( + <> + + {crossTeamTarget ? ( + + ) : null} + {crossTeamSentMemberName || !crossTeamTarget ? ( + + ) : null} + + ) : null; + + const summaryContent = + isSlashCommandResult && message.commandOutput ? ( + + + + {message.commandOutput.commandLabel} + + + {message.summary || getCommandOutputSummary(message.text) || rawSummary} + + + ) : isSlashCommandMessage && slashCommandMeta ? ( + + + + {slashCommandMeta.command} + + {slashCommandMeta.args ? ( + + {slashCommandMeta.args.replace(/\n+/g, ' ')} + + ) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( + + {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} + + ) : null} + + ) : onTaskIdClick ? ( + renderInlineBoldSummary(rawSummary, onTaskIdClick) + ) : ( + renderInlineBoldSummary(rawSummary) + ); return (
- {isUnread ? ( - - ) : null} - {/* Chevron for collapsible messages */} - {showChevron ? ( - - ) : null} - - {/* Sender avatar + name badge */} - {crossTeamOrigin ? ( - - ) : null} - {isSlashCommandResult ? ( - - result - - ) : ( - - )} - - {/* Role */} - {!compactHeader && formattedRole && !isSlashCommandResult ? ( - - {formattedRole} - - ) : null} - - {/* Message type label or system label */} - {systemLabel ? ( - - {systemLabel} - - ) : isSlashCommandResult && message.commandOutput ? ( - - {message.commandOutput.stream} - - ) : isSlashCommandMessage ? ( - command - ) : messageType ? ( - - {messageType} - - ) : null} - - {/* Lead session marker */} - {message.source === 'lead_session' && !isSlashCommandResult ? ( - - session - - ) : message.source === 'lead_process' && !isSlashCommandResult ? ( - - live - - ) : null} - - {/* Rate limit warning badge */} - {rateLimited ? ( - - - Rate Limited - - ) : null} - - {/* API Error warning badge */} - {isApiError && !rateLimited ? ( - - - API Error - - ) : null} - - {/* Recipient — arrow + avatar + badge */} - {message.to && message.to !== message.from ? ( - <> - - {crossTeamTarget ? ( - - ) : null} - {crossTeamSentMemberName || !crossTeamTarget ? ( - - ) : null} - - ) : null} - - {/* Summary */} - - {isSlashCommandResult && message.commandOutput ? ( - - - - {message.commandOutput.commandLabel} - - - {message.summary || getCommandOutputSummary(message.text) || rawSummary} - - - ) : isSlashCommandMessage && slashCommandMeta ? ( - - - - {slashCommandMeta.command} - - {slashCommandMeta.args ? ( - - {slashCommandMeta.args.replace(/\n+/g, ' ')} + {useCompactCollapsedHeader ? ( +
+
+
+ {isUnread ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} +
+
+ + {timestamp} - ) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( - - {slashCommandMeta.knownDescription ?? knownSlashCommand?.description} - - ) : null} - - ) : onTaskIdClick ? ( - renderInlineBoldSummary(rawSummary, onTaskIdClick) - ) : ( - renderInlineBoldSummary(rawSummary) - )} - - - {/* Timestamp / expand */} -
- - {timestamp} - - {onExpand && expandItemKey && ( - + )} +
+
+
- - - )} -
+ {summaryContent} +
+
+ ) : ( + <> + {isUnread ? ( + + ) : null} + {showChevron ? ( + + ) : null} + {crossTeamOrigin ? ( + + ) : null} + {senderBadge} + {!compactHeader && formattedRole && !isSlashCommandResult ? ( + + {formattedRole} + + ) : null} + {messageTypeBadge} + {leadSourceBadge} + {statusBadge} + {recipientBadge} + + {summaryContent} + +
+ + {timestamp} + + {onExpand && expandItemKey && ( + + )} +
+ + )}
{/* Content — collapsed for system messages, expanded for others */} diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 39c0508f..6943a68b 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -751,6 +751,13 @@ const LeadThoughtsGroupRowComponent = ({ }); }, []); + const timestampLabel = + formatTime(oldest.timestamp) === formatTime(newest.timestamp) + ? formatTime(oldest.timestamp) + : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`; + const useCompactCollapsedHeader = compactHeader && !isBodyVisible; + const compactPreviewText = headerTextPreview ?? totalToolSummary; + return (
- {/* Chevron for collapse mode */} - {canToggleBodyVisibility && !compactHeader ? ( - - ) : null} - {/* Lead avatar with optional live indicator */} - {!compactHeader ? ( -
- - -
- ) : null} - - - {thoughts.length} thoughts - - {!isBodyVisible && headerTextPreview ? ( - - - +
+
+ + + {thoughts.length} thoughts + +
+
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )} +
+
+ {compactPreviewText ? ( +
- {headerTextPreview} - - - {totalToolSummary ? ( - - - + {compactPreviewText} +
) : null} -
- ) : totalToolSummary ? ( - - - - {totalToolSummary} - - - - + ) : ( + <> + {canToggleBodyVisibility && !compactHeader ? ( + - - - ) : null} -
- - {formatTime(oldest.timestamp) === formatTime(newest.timestamp) - ? formatTime(oldest.timestamp) - : `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`} - - {onExpand && expandItemKey && ( - - )} -
+ ) : null} + {!compactHeader ? ( +
+ + +
+ ) : null} + + + {thoughts.length} thoughts + + {!isBodyVisible && headerTextPreview ? ( + + + + {headerTextPreview} + + + {totalToolSummary ? ( + + + + ) : null} + + ) : totalToolSummary ? ( + + + + {totalToolSummary} + + + + + + + ) : null} +
+ + {timestampLabel} + + {onExpand && expandItemKey && ( + + )} +
+ + )} {/* Scrollable body — live thoughts follow bottom unless user scrolls up */} diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 28a9ea58..62d5496c 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -16,6 +16,7 @@ import { Loader2, ShieldQuestion, Users } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { ResolvedTeamMember } from '@shared/types'; +import type { ReactNode } from 'react'; export interface PendingCrossTeamReply { teamName: string; @@ -26,6 +27,7 @@ interface PendingRepliesBlockProps { members: ResolvedTeamMember[]; pendingRepliesByMember: Record; pendingCrossTeamReplies?: PendingCrossTeamReply[]; + headerRight?: ReactNode; onMemberClick?: (member: ResolvedTeamMember) => void; } @@ -33,6 +35,7 @@ export const PendingRepliesBlock = ({ members, pendingRepliesByMember, pendingCrossTeamReplies = [], + headerRight, onMemberClick, }: PendingRepliesBlockProps): React.JSX.Element | null => { const { isLight } = useTheme(); @@ -70,9 +73,12 @@ export const PendingRepliesBlock = ({ return (
-

- Awaiting replies -

+
+

+ Awaiting replies +

+ {headerRight ?
{headerRight}
: null} +
{pending.map((entry) => { const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true }); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7b0957a1..b951cc04 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -465,7 +465,7 @@ export const MessageComposer = ({ return (
computePendingCrossTeamReplies(messages, nowMs), [messages, nowMs] ); - - /** Whether the Status block has any visible items. */ - const hasItems = useMemo(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) => + const hasPendingReplies = useMemo(() => { + const hasMemberPendingReplies = Object.keys(pendingRepliesByMember).some((name) => members.some((m) => m.name === name) ); - if (hasPendingReplies) return true; - if (pendingCrossTeamReplies.length > 0) return true; - + return hasMemberPendingReplies || pendingCrossTeamReplies.length > 0; + }, [members, pendingRepliesByMember, pendingCrossTeamReplies.length]); + const hasActiveTasks = useMemo(() => { const tMap = new Map(tasks.map((t) => [t.id, t])); return members.some((m) => { if (!m.currentTaskId) return false; @@ -60,7 +58,13 @@ export const StatusBlock = ({ if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; return true; }); - }, [members, tasks, pendingRepliesByMember, pendingCrossTeamReplies.length]); + }, [members, tasks]); + + /** Whether the Status block has any visible items. */ + const hasItems = useMemo(() => { + if (hasPendingReplies) return true; + return hasActiveTasks; + }, [hasActiveTasks, hasPendingReplies]); // Only run the 1-second timer when the block actually has content to show. useEffect(() => { @@ -85,6 +89,7 @@ export const StatusBlock = ({ Status ); + const flowInlineToggle = layout === 'flow' && !collapsed ? toggleButton : null; return ( <> @@ -92,21 +97,25 @@ export const StatusBlock = ({
{toggleButton}
- ) : ( + ) : collapsed ? (
{toggleButton}
- )} + ) : null} {!collapsed && (
- + {hasPendingReplies ? ( + + ) : null} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index a89b84e4..41861772 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -1102,10 +1102,10 @@ export const MentionableTextarea = React.forwardRef 0 && surfaceShellMetrics.height > 0 ? (