feat(ui): polish messages timeline and graph toolbar

This commit is contained in:
777genius 2026-04-13 10:41:23 +03:00
parent e5d6911025
commit 674c1d8b13
11 changed files with 535 additions and 339 deletions

View file

@ -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 { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
@ -19,6 +19,7 @@ interface ActiveTasksBlockProps {
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
/** Start collapsed (e.g. when rendered inside the sidebar where MemberList already shows status). */ /** Start collapsed (e.g. when rendered inside the sidebar where MemberList already shows status). */
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
headerRight?: ReactNode;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
onTaskClick?: (task: TeamTaskWithKanban) => void; onTaskClick?: (task: TeamTaskWithKanban) => void;
} }
@ -34,6 +35,7 @@ export const ActiveTasksBlock = ({
members, members,
tasks, tasks,
defaultCollapsed = false, defaultCollapsed = false,
headerRight,
onMemberClick, onMemberClick,
onTaskClick, onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null => { }: ActiveTasksBlockProps): React.JSX.Element | null => {
@ -70,23 +72,26 @@ export const ActiveTasksBlock = ({
return ( return (
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
<button <div className="flex items-center justify-between gap-3">
type="button" <button
className="flex w-full items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]" type="button"
onClick={() => setCollapsed((v) => !v)} className="flex min-w-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
aria-label={collapsed ? 'Expand in progress' : 'Collapse in progress'} onClick={() => setCollapsed((v) => !v)}
> aria-label={collapsed ? 'Expand in progress' : 'Collapse in progress'}
<ChevronRight >
size={10} <ChevronRight
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`} size={10}
/> className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
<span>In progress</span> />
{collapsed && ( <span>In progress</span>
<span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none text-[var(--color-text-muted)]"> {collapsed && (
{entries.length} <span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none text-[var(--color-text-muted)]">
</span> {entries.length}
)} </span>
</button> )}
</button>
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
</div>
{!collapsed && {!collapsed &&
entries.map(({ member, task, taskId, kind }) => { entries.map(({ member, task, taskId, kind }) => {
const colors = getTeamColorSet(colorMap.get(member.name) ?? ''); const colors = getTeamColorSet(colorMap.get(member.name) ?? '');

View file

@ -881,6 +881,132 @@ export const ActivityItem = memo(
onToggleCollapse?.(collapseToggleKey); onToggleCollapse?.(collapseToggleKey);
} }
}, [collapseToggleKey, isHeaderClickable, onToggleCollapse]); }, [collapseToggleKey, isHeaderClickable, onToggleCollapse]);
const useCompactCollapsedHeader = compactHeader && !isExpanded;
const senderBadge = isSlashCommandResult ? (
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
result
</span>
) : (
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar={senderHideAvatar || compactHeader}
onClick={onMemberNameClick}
disableHoverCard={crossTeamOrigin != null}
/>
);
const messageTypeBadge = systemLabel ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{systemLabel}
</span>
) : isSlashCommandResult && message.commandOutput ? (
<span
className={[
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide',
isCommandOutputError ? 'bg-rose-500/15 text-rose-300' : 'bg-amber-500/15 text-amber-300',
].join(' ')}
>
{message.commandOutput.stream}
</span>
) : isSlashCommandMessage ? (
<span className="text-[10px] uppercase tracking-wide text-amber-400">command</span>
) : messageType ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{messageType}
</span>
) : null;
const leadSourceBadge =
message.source === 'lead_session' && !isSlashCommandResult ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
session
</span>
) : message.source === 'lead_process' && !isSlashCommandResult ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
live
</span>
) : null;
const statusBadge = rateLimited ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
Rate Limited
</span>
) : isApiError ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
API Error
</span>
) : null;
const recipientBadge =
message.to && message.to !== message.from ? (
<>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
{crossTeamTarget ? (
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
) : null}
{crossTeamSentMemberName || !crossTeamTarget ? (
<MemberBadge
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
color={crossTeamTarget ? undefined : recipientColor}
teamName={crossTeamTarget ? undefined : teamName}
hideAvatar={
compactHeader ||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) === 'user'
}
onClick={onMemberNameClick}
disableHoverCard={crossTeamTarget != null}
/>
) : null}
</>
) : null;
const summaryContent =
isSlashCommandResult && message.commandOutput ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Command
size={12}
className={['shrink-0', isCommandOutputError ? 'text-rose-400' : 'text-amber-400'].join(
' '
)}
/>
<span
className={[
'shrink-0 font-mono text-[11px]',
isCommandOutputError ? 'text-rose-300' : 'text-amber-300',
].join(' ')}
>
{message.commandOutput.commandLabel}
</span>
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{message.summary || getCommandOutputSummary(message.text) || rawSummary}
</span>
</span>
) : isSlashCommandMessage && slashCommandMeta ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Command size={12} className="shrink-0 text-amber-400" />
<span className="shrink-0 font-mono text-[11px] text-amber-300">
{slashCommandMeta.command}
</span>
{slashCommandMeta.args ? (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.args.replace(/\n+/g, ' ')}
</span>
) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.knownDescription ?? knownSlashCommand?.description}
</span>
) : null}
</span>
) : onTaskIdClick ? (
renderInlineBoldSummary(rawSummary, onTaskIdClick)
) : (
renderInlineBoldSummary(rawSummary)
);
return ( return (
<article <article
@ -933,7 +1059,9 @@ export const ActivityItem = memo(
role={isHeaderClickable ? 'button' : undefined} role={isHeaderClickable ? 'button' : undefined}
tabIndex={isHeaderClickable ? 0 : undefined} tabIndex={isHeaderClickable ? 0 : undefined}
className={[ className={[
'flex min-w-0 items-center gap-2 px-3 py-2', useCompactCollapsedHeader
? 'min-w-0 px-3 py-2'
: 'flex min-w-0 items-center gap-2 px-3 py-2',
isHeaderClickable ? 'cursor-pointer select-none' : '', isHeaderClickable ? 'cursor-pointer select-none' : '',
].join(' ')} ].join(' ')}
onClick={handleHeaderToggle} onClick={handleHeaderToggle}
@ -948,207 +1076,125 @@ export const ActivityItem = memo(
: undefined : undefined
} }
> >
{isUnread ? ( {useCompactCollapsedHeader ? (
<span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden /> <div className="min-w-0">
) : null} <div className="flex min-w-0 items-start gap-3">
{/* Chevron for collapsible messages */} <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
{showChevron ? ( {isUnread ? (
<ChevronRight <span
className="size-3 shrink-0 transition-transform duration-150" className="size-2 shrink-0 rounded-full bg-blue-500"
style={{ title="Unread"
color: CARD_ICON_MUTED, aria-hidden
transform: isExpanded ? 'rotate(90deg)' : undefined, />
}} ) : null}
/> {crossTeamOrigin ? (
) : null} <CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
) : null}
{/* Sender avatar + name badge */} {senderBadge}
{crossTeamOrigin ? ( {messageTypeBadge}
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} /> {leadSourceBadge}
) : null} {statusBadge}
{isSlashCommandResult ? ( {recipientBadge}
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"> </div>
result <div className="relative flex shrink-0 items-center">
</span> <span
) : ( className={
<MemberBadge onExpand && expandItemKey
name={senderName} ? 'text-[10px] transition-opacity group-hover:opacity-0'
color={senderColor} : 'text-[10px]'
teamName={teamName} }
hideAvatar={senderHideAvatar || compactHeader} style={{ color: CARD_ICON_MUTED }}
onClick={onMemberNameClick} >
disableHoverCard={crossTeamOrigin != null} {timestamp}
/>
)}
{/* Role */}
{!compactHeader && formattedRole && !isSlashCommandResult ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
) : null}
{/* Message type label or system label */}
{systemLabel ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
>
{systemLabel}
</span>
) : isSlashCommandResult && message.commandOutput ? (
<span
className={[
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide',
isCommandOutputError
? 'bg-rose-500/15 text-rose-300'
: 'bg-amber-500/15 text-amber-300',
].join(' ')}
>
{message.commandOutput.stream}
</span>
) : isSlashCommandMessage ? (
<span className="text-[10px] uppercase tracking-wide text-amber-400">command</span>
) : messageType ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
>
{messageType}
</span>
) : null}
{/* Lead session marker */}
{message.source === 'lead_session' && !isSlashCommandResult ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
>
session
</span>
) : message.source === 'lead_process' && !isSlashCommandResult ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
>
live
</span>
) : null}
{/* Rate limit warning badge */}
{rateLimited ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
Rate Limited
</span>
) : null}
{/* API Error warning badge */}
{isApiError && !rateLimited ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<AlertTriangle size={10} />
API Error
</span>
) : null}
{/* Recipient — arrow + avatar + badge */}
{message.to && message.to !== message.from ? (
<>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
{crossTeamTarget ? (
<CrossTeamTeamBadge teamName={crossTeamTarget} onClick={onTeamClick} />
) : null}
{crossTeamSentMemberName || !crossTeamTarget ? (
<MemberBadge
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
color={crossTeamTarget ? undefined : recipientColor}
teamName={crossTeamTarget ? undefined : teamName}
hideAvatar={
compactHeader ||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===
'user'
}
onClick={onMemberNameClick}
disableHoverCard={crossTeamTarget != null}
/>
) : null}
</>
) : null}
{/* Summary */}
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{isSlashCommandResult && message.commandOutput ? (
<span className="inline-flex items-center gap-1.5">
<Command
size={12}
className={[
'shrink-0',
isCommandOutputError ? 'text-rose-400' : 'text-amber-400',
].join(' ')}
/>
<span
className={[
'font-mono text-[11px]',
isCommandOutputError ? 'text-rose-300' : 'text-amber-300',
].join(' ')}
>
{message.commandOutput.commandLabel}
</span>
<span className="truncate text-[11px] text-[var(--color-text-secondary)]">
{message.summary || getCommandOutputSummary(message.text) || rawSummary}
</span>
</span>
) : isSlashCommandMessage && slashCommandMeta ? (
<span className="inline-flex items-center gap-1.5">
<Command size={12} className="shrink-0 text-amber-400" />
<span className="font-mono text-[11px] text-amber-300">
{slashCommandMeta.command}
</span>
{slashCommandMeta.args ? (
<span className="truncate text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.args.replace(/\n+/g, ' ')}
</span> </span>
) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? ( {onExpand && expandItemKey && (
<span className="truncate text-[11px] text-[var(--color-text-secondary)]"> <button
{slashCommandMeta.knownDescription ?? knownSlashCommand?.description} type="button"
</span> aria-label="Expand message"
) : null} className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
</span> style={{ color: CARD_ICON_MUTED }}
) : onTaskIdClick ? ( onClick={(e) => {
renderInlineBoldSummary(rawSummary, onTaskIdClick) e.stopPropagation();
) : ( onExpand(expandItemKey);
renderInlineBoldSummary(rawSummary) }}
)} onKeyDown={(e) => e.stopPropagation()}
</span> >
<Maximize2 size={12} />
{/* Timestamp / expand */} </button>
<div className="relative flex shrink-0 items-center"> )}
<span </div>
className={ </div>
onExpand && expandItemKey <div
? 'text-[10px] transition-opacity group-hover:opacity-0' className="mt-1 min-w-0 truncate text-[11px]"
: 'text-[10px]' style={{ color: CARD_TEXT_LIGHT }}
} title={summaryText || rawSummary}
style={{ color: CARD_ICON_MUTED }}
>
{timestamp}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand message"
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
> >
<Maximize2 size={12} /> {summaryContent}
</button> </div>
)} </div>
</div> ) : (
<>
{isUnread ? (
<span
className="size-2 shrink-0 rounded-full bg-blue-500"
title="Unread"
aria-hidden
/>
) : null}
{showChevron ? (
<ChevronRight
className="size-3 shrink-0 transition-transform duration-150"
style={{
color: CARD_ICON_MUTED,
transform: isExpanded ? 'rotate(90deg)' : undefined,
}}
/>
) : null}
{crossTeamOrigin ? (
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
) : null}
{senderBadge}
{!compactHeader && formattedRole && !isSlashCommandResult ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
) : null}
{messageTypeBadge}
{leadSourceBadge}
{statusBadge}
{recipientBadge}
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryContent}
</span>
<div className="relative flex shrink-0 items-center">
<span
className={
onExpand && expandItemKey
? 'text-[10px] transition-opacity group-hover:opacity-0'
: 'text-[10px]'
}
style={{ color: CARD_ICON_MUTED }}
>
{timestamp}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand message"
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</>
)}
</div> </div>
{/* Content — collapsed for system messages, expanded for others */} {/* Content — collapsed for system messages, expanded for others */}

View file

@ -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 ( return (
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}> <AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
<article <article
@ -767,7 +774,9 @@ const LeadThoughtsGroupRowComponent = ({
role={canToggleBodyVisibility ? 'button' : undefined} role={canToggleBodyVisibility ? 'button' : undefined}
tabIndex={canToggleBodyVisibility ? 0 : undefined} tabIndex={canToggleBodyVisibility ? 0 : undefined}
className={[ className={[
'flex select-none items-center gap-2 px-3 py-1.5', useCompactCollapsedHeader
? 'select-none px-3 py-2'
: 'flex select-none items-center gap-2 px-3 py-1.5',
canToggleBodyVisibility ? 'cursor-pointer' : '', canToggleBodyVisibility ? 'cursor-pointer' : '',
].join(' ')} ].join(' ')}
style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined} style={hasApiError ? { backgroundColor: 'rgba(248, 113, 113, 0.08)' } : undefined}
@ -783,101 +792,148 @@ const LeadThoughtsGroupRowComponent = ({
: undefined : undefined
} }
> >
{/* Chevron for collapse mode */} {useCompactCollapsedHeader ? (
{canToggleBodyVisibility && !compactHeader ? ( <div className="min-w-0">
<ChevronRight <div className="flex min-w-0 items-start gap-3">
className="size-3 shrink-0 transition-transform duration-150" <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
style={{ <MemberBadge name={leadName} color={memberColor} hideAvatar />
color: CARD_ICON_MUTED, <span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
transform: isBodyVisible ? 'rotate(90deg)' : undefined, {thoughts.length} thoughts
}} </span>
/> </div>
) : null} <div className="relative flex shrink-0 items-center">
{/* Lead avatar with optional live indicator */} <span
{!compactHeader ? ( className={
<div className="relative shrink-0"> onExpand && expandItemKey
<img ? 'text-[10px] transition-opacity group-hover:opacity-0'
src={agentAvatarUrl(leadName, 24)} : 'text-[10px]'
alt="" }
className="size-5 rounded-full bg-[var(--color-surface-raised)]" style={{ color: CARD_ICON_MUTED }}
loading="lazy" >
/> {timestampLabel}
<LiveThoughtStatusBadge </span>
canBeLive={canBeLive} {onExpand && expandItemKey && (
isTeamAlive={isTeamAlive} <button
leadActivity={leadActivity} type="button"
leadContextUpdatedAt={leadContextUpdatedAt} aria-label="Expand thoughts"
newestTimestamp={newest.timestamp} className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
/> style={{ color: CARD_ICON_MUTED }}
</div> onClick={(e) => {
) : null} e.stopPropagation();
<MemberBadge name={leadName} color={memberColor} hideAvatar /> onExpand(expandItemKey);
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> }}
{thoughts.length} thoughts onKeyDown={(e) => e.stopPropagation()}
</span> >
{!isBodyVisible && headerTextPreview ? ( <Maximize2 size={12} />
<Tooltip> </button>
<TooltipTrigger asChild> )}
<span </div>
className="min-w-0 flex-1 cursor-default truncate text-[10px]" </div>
style={{ color: CARD_TEXT_LIGHT }} {compactPreviewText ? (
<div
className="mt-1 min-w-0 truncate text-[11px]"
style={{ color: headerTextPreview ? CARD_TEXT_LIGHT : CARD_ICON_MUTED }}
title={compactPreviewText}
> >
{headerTextPreview} {compactPreviewText}
</span> </div>
</TooltipTrigger>
{totalToolSummary ? (
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
) : null} ) : null}
</Tooltip> </div>
) : totalToolSummary ? ( ) : (
<Tooltip> <>
<TooltipTrigger asChild> {canToggleBodyVisibility && !compactHeader ? (
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}> <ChevronRight
{totalToolSummary} className="size-3 shrink-0 transition-transform duration-150"
</span> style={{
</TooltipTrigger> color: CARD_ICON_MUTED,
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]"> transform: isBodyVisible ? 'rotate(90deg)' : undefined,
<ToolSummaryTooltipContent }}
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/> />
</TooltipContent> ) : null}
</Tooltip> {!compactHeader ? (
) : null} <div className="relative shrink-0">
<div className="relative ml-auto flex shrink-0 items-center"> <img
<span src={agentAvatarUrl(leadName, 24)}
className={ alt=""
onExpand && expandItemKey className="size-5 rounded-full bg-[var(--color-surface-raised)]"
? 'text-[10px] transition-opacity group-hover:opacity-0' loading="lazy"
: 'text-[10px]' />
} <LiveThoughtStatusBadge
style={{ color: CARD_ICON_MUTED }} canBeLive={canBeLive}
> isTeamAlive={isTeamAlive}
{formatTime(oldest.timestamp) === formatTime(newest.timestamp) leadActivity={leadActivity}
? formatTime(oldest.timestamp) leadContextUpdatedAt={leadContextUpdatedAt}
: `${formatTime(oldest.timestamp)}${formatTime(newest.timestamp)}`} newestTimestamp={newest.timestamp}
</span> />
{onExpand && expandItemKey && ( </div>
<button ) : null}
type="button" <MemberBadge name={leadName} color={memberColor} hideAvatar />
aria-label="Expand thoughts" <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100" {thoughts.length} thoughts
style={{ color: CARD_ICON_MUTED }} </span>
onClick={(e) => { {!isBodyVisible && headerTextPreview ? (
e.stopPropagation(); <Tooltip>
onExpand(expandItemKey); <TooltipTrigger asChild>
}} <span
onKeyDown={(e) => e.stopPropagation()} className="min-w-0 flex-1 cursor-default truncate text-[10px]"
> style={{ color: CARD_TEXT_LIGHT }}
<Maximize2 size={12} /> >
</button> {headerTextPreview}
)} </span>
</div> </TooltipTrigger>
{totalToolSummary ? (
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
) : null}
</Tooltip>
) : totalToolSummary ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{totalToolSummary}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
<ToolSummaryTooltipContent
toolCalls={allToolCalls}
toolSummary={totalToolSummary}
/>
</TooltipContent>
</Tooltip>
) : null}
<div className="relative ml-auto flex shrink-0 items-center">
<span
className={
onExpand && expandItemKey
? 'text-[10px] transition-opacity group-hover:opacity-0'
: 'text-[10px]'
}
style={{ color: CARD_ICON_MUTED }}
>
{timestampLabel}
</span>
{onExpand && expandItemKey && (
<button
type="button"
aria-label="Expand thoughts"
className="absolute right-0 top-1/2 -translate-y-1/2 rounded p-0.5 opacity-0 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500/50 group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onExpand(expandItemKey);
}}
onKeyDown={(e) => e.stopPropagation()}
>
<Maximize2 size={12} />
</button>
)}
</div>
</>
)}
</div> </div>
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */} {/* Scrollable body — live thoughts follow bottom unless user scrolls up */}

View file

@ -16,6 +16,7 @@ import { Loader2, ShieldQuestion, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember } from '@shared/types';
import type { ReactNode } from 'react';
export interface PendingCrossTeamReply { export interface PendingCrossTeamReply {
teamName: string; teamName: string;
@ -26,6 +27,7 @@ interface PendingRepliesBlockProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
pendingRepliesByMember: Record<string, number>; pendingRepliesByMember: Record<string, number>;
pendingCrossTeamReplies?: PendingCrossTeamReply[]; pendingCrossTeamReplies?: PendingCrossTeamReply[];
headerRight?: ReactNode;
onMemberClick?: (member: ResolvedTeamMember) => void; onMemberClick?: (member: ResolvedTeamMember) => void;
} }
@ -33,6 +35,7 @@ export const PendingRepliesBlock = ({
members, members,
pendingRepliesByMember, pendingRepliesByMember,
pendingCrossTeamReplies = [], pendingCrossTeamReplies = [],
headerRight,
onMemberClick, onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null => { }: PendingRepliesBlockProps): React.JSX.Element | null => {
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -70,9 +73,12 @@ export const PendingRepliesBlock = ({
return ( return (
<div className="mb-3 space-y-1.5"> <div className="mb-3 space-y-1.5">
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]"> <div className="flex items-center justify-between gap-3">
Awaiting replies <p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
</p> Awaiting replies
</p>
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
</div>
{pending.map((entry) => { {pending.map((entry) => {
const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true }); const since = formatDistanceToNowStrict(entry.sentAtMs, { addSuffix: true });

View file

@ -465,7 +465,7 @@ export const MessageComposer = ({
return ( return (
<div <div
className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-3 pb-3')} className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-1.5 pb-1.5')}
role="group" role="group"
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View file

@ -648,6 +648,7 @@ export const MessagesPanel = memo(function MessagesPanel({
tasks={tasks} tasks={tasks}
messages={effectiveMessages} messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline" position="inline"
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
@ -831,6 +832,7 @@ export const MessagesPanel = memo(function MessagesPanel({
tasks={tasks} tasks={tasks}
messages={effectiveMessages} messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="sidebar" position="sidebar"
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}

View file

@ -44,15 +44,13 @@ export const StatusBlock = ({
() => computePendingCrossTeamReplies(messages, nowMs), () => computePendingCrossTeamReplies(messages, nowMs),
[messages, nowMs] [messages, nowMs]
); );
const hasPendingReplies = useMemo(() => {
/** Whether the Status block has any visible items. */ const hasMemberPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
const hasItems = useMemo(() => {
const hasPendingReplies = Object.keys(pendingRepliesByMember).some((name) =>
members.some((m) => m.name === name) members.some((m) => m.name === name)
); );
if (hasPendingReplies) return true; return hasMemberPendingReplies || pendingCrossTeamReplies.length > 0;
if (pendingCrossTeamReplies.length > 0) return true; }, [members, pendingRepliesByMember, pendingCrossTeamReplies.length]);
const hasActiveTasks = useMemo(() => {
const tMap = new Map(tasks.map((t) => [t.id, t])); const tMap = new Map(tasks.map((t) => [t.id, t]));
return members.some((m) => { return members.some((m) => {
if (!m.currentTaskId) return false; if (!m.currentTaskId) return false;
@ -60,7 +58,13 @@ export const StatusBlock = ({
if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false; if (task && (task.reviewState === 'approved' || task.status === 'completed')) return false;
return true; 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. // Only run the 1-second timer when the block actually has content to show.
useEffect(() => { useEffect(() => {
@ -85,6 +89,7 @@ export const StatusBlock = ({
Status Status
</button> </button>
); );
const flowInlineToggle = layout === 'flow' && !collapsed ? toggleButton : null;
return ( return (
<> <>
@ -92,21 +97,25 @@ export const StatusBlock = ({
<div className="relative h-0"> <div className="relative h-0">
<div className="absolute -top-[19px] right-0 z-10">{toggleButton}</div> <div className="absolute -top-[19px] right-0 z-10">{toggleButton}</div>
</div> </div>
) : ( ) : collapsed ? (
<div className="mb-2 flex justify-end">{toggleButton}</div> <div className="mb-2 flex justify-end">{toggleButton}</div>
)} ) : null}
{!collapsed && ( {!collapsed && (
<div className={layout === 'overlay' ? 'mt-5' : ''}> <div className={layout === 'overlay' ? 'mt-5' : ''}>
<PendingRepliesBlock {hasPendingReplies ? (
members={members} <PendingRepliesBlock
pendingRepliesByMember={pendingRepliesByMember} members={members}
pendingCrossTeamReplies={pendingCrossTeamReplies} pendingRepliesByMember={pendingRepliesByMember}
onMemberClick={onMemberClick} pendingCrossTeamReplies={pendingCrossTeamReplies}
/> headerRight={flowInlineToggle}
onMemberClick={onMemberClick}
/>
) : null}
<ActiveTasksBlock <ActiveTasksBlock
members={members} members={members}
tasks={tasks} tasks={tasks}
defaultCollapsed={position === 'sidebar'} defaultCollapsed={position === 'sidebar'}
headerRight={!hasPendingReplies ? flowInlineToggle : undefined}
onMemberClick={onMemberClick} onMemberClick={onMemberClick}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
/> />

View file

@ -1102,10 +1102,10 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const orbitTrackWidth = 1; const orbitTrackWidth = 1;
const orbitStrokeWidth = 1.35; const orbitStrokeWidth = 1.35;
const orbitGlowWidth = 3; const orbitGlowWidth = 3;
const orbitInset = orbitTrackWidth / 2; const orbitInset = 0;
const orbitWidth = Math.max(surfaceShellMetrics.width - orbitTrackWidth, 0); const orbitWidth = Math.max(surfaceShellMetrics.width, 0);
const orbitHeight = Math.max(surfaceShellMetrics.height - orbitTrackWidth, 0); const orbitHeight = Math.max(surfaceShellMetrics.height, 0);
const orbitRadius = Math.max(surfaceShellMetrics.borderRadius - orbitInset, 0); const orbitRadius = Math.max(surfaceShellMetrics.borderRadius, 0);
const orbitRight = orbitInset + orbitWidth; const orbitRight = orbitInset + orbitWidth;
const orbitBottom = orbitInset + orbitHeight; const orbitBottom = orbitInset + orbitHeight;
const orbitMidX = orbitInset + orbitWidth / 2; const orbitMidX = orbitInset + orbitWidth / 2;
@ -1134,7 +1134,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
surfaceShellMetrics.width > 0 && surfaceShellMetrics.width > 0 &&
surfaceShellMetrics.height > 0 ? ( surfaceShellMetrics.height > 0 ? (
<svg <svg
className="message-composer-orbit-svg pointer-events-none absolute inset-0 z-[1] h-full w-full" className="message-composer-orbit-svg pointer-events-none absolute inset-0 z-[16] h-full w-full"
viewBox={`0 0 ${surfaceShellMetrics.width} ${surfaceShellMetrics.height}`} viewBox={`0 0 ${surfaceShellMetrics.width} ${surfaceShellMetrics.height}`}
aria-hidden="true" aria-hidden="true"
> >

View file

@ -84,7 +84,7 @@ export const TeamGraphOverlay = ({
events={events} events={events}
onRequestClose={onClose} onRequestClose={onClose}
onRequestPinAsTab={onPinAsTab} onRequestPinAsTab={onPinAsTab}
className="min-w-0 flex-1" className="team-graph-view min-w-0 flex-1"
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
<GraphBlockingEdgePopover <GraphBlockingEdgePopover
teamName={teamName} teamName={teamName}

View file

@ -114,7 +114,7 @@ export const TeamGraphTab = ({
<GraphView <GraphView
data={graphData} data={graphData}
events={events} events={events}
className="size-full" className="team-graph-view size-full"
suspendAnimation={!isActive} suspendAnimation={!isActive}
onRequestFullscreen={() => setFullscreen(true)} onRequestFullscreen={() => setFullscreen(true)}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (

View file

@ -1367,7 +1367,7 @@ body.theme-transitioning {
.message-composer-orbit-glow { .message-composer-orbit-glow {
stroke-dasharray: 9 91; stroke-dasharray: 9 91;
animation: composer-orbit-border 19s linear infinite; animation: composer-orbit-border 19s linear infinite;
stroke-linecap: round; stroke-linecap: butt;
stroke-linejoin: round; stroke-linejoin: round;
will-change: stroke-dashoffset; will-change: stroke-dashoffset;
} }
@ -1418,6 +1418,78 @@ body.theme-transitioning {
stroke: rgba(138, 130, 118, 0.2); stroke: rgba(138, 130, 118, 0.2);
} }
:root.light .team-graph-view > .absolute.left-20.top-3 {
display: none !important;
}
:root.light .team-graph-view > .absolute.right-3.top-3 > div:not(.relative),
:root.light .team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child,
:root.light .team-graph-view > .absolute.bottom-3.right-3 > div,
:root.light .team-graph-view > .absolute.bottom-3.left-3 > div {
background: rgba(248, 244, 237, 0.9) !important;
border-color: rgba(120, 113, 108, 0.18) !important;
box-shadow: 0 10px 24px rgba(120, 113, 108, 0.1);
}
:root.light .team-graph-view > .absolute.right-3.top-3 > .relative > .absolute {
background: rgba(248, 244, 237, 0.96) !important;
border-color: rgba(120, 113, 108, 0.18) !important;
box-shadow: 0 16px 32px rgba(120, 113, 108, 0.12);
}
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative),
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child {
width: 32px !important;
min-width: 32px !important;
height: 32px !important;
min-height: 32px !important;
padding: 0 !important;
justify-content: center !important;
border-radius: 10px !important;
}
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button {
width: 100% !important;
height: 100% !important;
min-width: 100% !important;
min-height: 100% !important;
padding: 6px !important;
justify-content: center !important;
gap: 0 !important;
}
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button svg,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button svg {
width: 100% !important;
height: 100% !important;
}
.team-graph-view > .absolute.right-3.top-3 > div:not(.relative) > button span,
.team-graph-view > .absolute.right-3.top-3 > .relative > div:first-child > button span {
display: none !important;
}
:root.light .team-graph-view button[class*='font-mono'] {
color: rgba(70, 85, 125, 0.9) !important;
}
:root.light .team-graph-view button[class*='font-mono']:hover {
color: #334155 !important;
background: rgba(79, 70, 229, 0.08) !important;
}
:root.light
.team-graph-view
button[class*='font-mono'][class*='bg-\\[rgba\\(100\\,200\\,255\\,0\\.14\\)\\]'] {
color: #4338ca !important;
background: rgba(79, 70, 229, 0.12) !important;
}
:root.light .team-graph-view button[class*='font-mono'] svg {
opacity: 0.96;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.message-composer-orbit-path, .message-composer-orbit-path,
.message-composer-orbit-glow { .message-composer-orbit-glow {