mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
3754d1f2a5
Sessions get a Chat / Trajectory switch in the header. The trajectory is a dense one-line-per-event ledger (system prompt, user, injected context such as skills and system reminders, assistant responses, tool calls) with a three-lane minimap, turn folding, search, and a detail panel per row. - CLI: record a deduplicated prompt snapshot sidecar for desktop sessions (system prompt, tool catalog, user context) without touching the transcript. - Server: project transcript records into trajectory rows with bounded, cursor-paged reads, live appends, a turn index, row detail by byte range, snapshot blobs, and a time-window lookup into the raw trace capture. - Desktop: windowed ledger, minimap, detail panel with a raw request summary, chat <-> trajectory navigation, subagent drill-in. - Remove the Settings trace list, trace tabs and the standalone trace window; migrate persisted trace tabs to session tabs.
4122 lines
162 KiB
TypeScript
4122 lines
162 KiB
TypeScript
import { openSideChat } from '@/lib/workspace/openSideChat'
|
|
import { useRef, useEffect, useMemo, memo, useState, useCallback, useDeferredValue, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
import { createPortal, flushSync } from 'react-dom'
|
|
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, FileStack, LoaderCircle, Settings, Target, Undo2, XCircle } from 'lucide-react'
|
|
import { ApiError } from '../../api/client'
|
|
import { sessionsApi, type SessionRewindMode, type SessionTurnCheckpoint, type WorkspaceChangedFile } from '../../api/sessions'
|
|
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useSideChatStore } from '../../stores/sideChatStore'
|
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
|
import { useWorkspaceStore, type WorkspaceOrigin } from '../../stores/workspaceStore'
|
|
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
|
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import { useChatAppearanceStore } from '../../stores/chatAppearanceStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import type { TranslationKey } from '../../i18n/locales/en'
|
|
import { UserMessage } from './UserMessage'
|
|
import { AssistantMessage } from './AssistantMessage'
|
|
import { ThinkingBlock } from './ThinkingBlock'
|
|
import { ToolCallBlock } from './ToolCallBlock'
|
|
import {
|
|
ToolCallGroup,
|
|
type OpenAgentRunPayload,
|
|
type ResolveAgentActivityTarget,
|
|
} from './ToolCallGroup'
|
|
import type { ActivityStep } from './activityGroupModel'
|
|
import { ToolResultBlock } from './ToolResultBlock'
|
|
import { PermissionDialog } from './PermissionDialog'
|
|
import { AskUserQuestion } from './AskUserQuestion'
|
|
import { RenderItemBoundary } from './RenderItemBoundary'
|
|
import { StreamingIndicator } from './StreamingIndicator'
|
|
import { InlineTaskSummary } from './InlineTaskSummary'
|
|
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
|
import { describeRewindResult, getApiErrorMessage, rewindToTurnCheckpoint } from './turnRewind'
|
|
import { useUserMessageEditResend } from './useUserMessageEditResend'
|
|
import type { UserMessageEditAction } from './UserMessage'
|
|
import { WorkspaceChangesFallback } from '@/components/chat/WorkspaceChangesFallback'
|
|
import { AgentTeamsInlineCard } from '../agentTeams/AgentTeamsSummary'
|
|
import { MEMBER_AVATARS, memberAccentColor } from '../agentTeams/agentTeamsAvatars'
|
|
import {
|
|
getMemberAvatarKey,
|
|
resolveTeamMemberIdentity,
|
|
snapshotWithHistoricalMembers,
|
|
} from '../agentTeams/agentTeamsModel'
|
|
import {
|
|
buildConversationNavigationItems,
|
|
ConversationNavigator,
|
|
type ConversationNavigationItem,
|
|
type ConversationNavigationMode,
|
|
} from './ConversationNavigator'
|
|
import type { AgentTaskNotification, BackgroundAgentTask, UIMessage } from '../../types/chat'
|
|
import type { TeamDetail, TeamWorkbenchSnapshot } from '../../types/team'
|
|
import { formatTokenCount } from '../../lib/formatTokenCount'
|
|
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
|
|
import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion'
|
|
import { isTouchH5Document } from '../../lib/touchH5'
|
|
import { findChatRenderTarget } from '../../lib/trajectory/chatTarget'
|
|
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
|
|
import {
|
|
EMPTY_TEAM_LIFECYCLE_CURSOR,
|
|
isTeamLifecycleScopedAt,
|
|
updateTeamLifecycleCursor,
|
|
} from '../../lib/teamLifecycleScope'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { ActionDialog, type ActionDialogAction } from '@/components/ui/ActionDialog'
|
|
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
|
import {
|
|
getHeightsForSession,
|
|
getMetricsForSession,
|
|
type VirtualRenderItemMetric,
|
|
} from './virtualHeightCache'
|
|
import {
|
|
notifyConversationFindContentChanged,
|
|
registerConversationFindController,
|
|
type ConversationFindController,
|
|
} from '../search/conversationFindBridge'
|
|
|
|
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
|
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
|
type MemoryEvent = Extract<UIMessage, { type: 'memory_event' }>
|
|
type GoalEvent = Extract<UIMessage, { type: 'goal_event' }>
|
|
type BackgroundTaskEvent = Extract<UIMessage, { type: 'background_task' }>
|
|
type CompactSummaryEvent = Extract<UIMessage, { type: 'compact_summary' }>
|
|
|
|
type RenderItem =
|
|
/**
|
|
* One contiguous activity run. `steps` is the run in transcript order —
|
|
* thinking blocks included — and `toolCalls` is the tools-only projection the
|
|
* agent/image/memory renderers still work from.
|
|
*/
|
|
| { kind: 'tool_group'; toolCalls: ToolCall[]; steps: ActivityStep[]; id: string }
|
|
| { kind: 'message'; message: UIMessage }
|
|
/**
|
|
* Stands in for the TeamCreate call so the transcript records that this turn
|
|
* handed work to a team, without expanding into the workbench inline.
|
|
*/
|
|
| {
|
|
kind: 'team_card'
|
|
id: string
|
|
teamName: string
|
|
startedAt: number
|
|
endedAt?: number
|
|
coordinationToolCalls: ToolCall[]
|
|
}
|
|
|
|
type RenderModel = {
|
|
renderItems: RenderItem[]
|
|
toolResultMap: Map<string, ToolResult>
|
|
childToolCallsByParent: Map<string, ToolCall[]>
|
|
/**
|
|
* Unresolved AskUserQuestion tool_use ids followed by a user message. The user
|
|
* has already spoken past that question, so no live permission request can
|
|
* still be on its way to it — the card is history, not a prompt.
|
|
*/
|
|
supersededAskUserQuestionIds: ReadonlySet<string>
|
|
}
|
|
|
|
type RewindTurnTarget = {
|
|
messageId: string
|
|
userMessageIndex: number
|
|
content: string
|
|
expectedContent: string
|
|
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
|
|
}
|
|
|
|
type BranchableMessageTarget = {
|
|
uiMessageId: string
|
|
transcriptMessageId: string
|
|
}
|
|
|
|
type TurnChangeCardModel = {
|
|
target: RewindTurnTarget
|
|
checkpoint: SessionTurnCheckpoint
|
|
workDir: string | null
|
|
isLatest: boolean
|
|
}
|
|
|
|
const EMPTY_TURN_CHANGE_CARDS: TurnChangeCardModel[] = []
|
|
|
|
type ChatMessageRole = 'user' | 'assistant'
|
|
|
|
type ChatSelectionState = {
|
|
text: string
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
type SelectionPointer = {
|
|
clientX: number
|
|
clientY: number
|
|
}
|
|
|
|
const CHAT_SELECTION_MENU_OFFSET = 10
|
|
const CHAT_SELECTION_MENU_WIDTH = 360
|
|
const CHAT_SELECTION_MENU_HEIGHT = 44
|
|
|
|
function getElementForNode(node: Node | null): Element | null {
|
|
if (!node) return null
|
|
return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
|
|
}
|
|
|
|
function getChatSelectionPosition(
|
|
range: Range,
|
|
root: HTMLElement,
|
|
selection: Selection,
|
|
pointer: { clientX: number; clientY: number },
|
|
) {
|
|
return getSelectionPopoverPosition(range, root, {
|
|
menuWidth: CHAT_SELECTION_MENU_WIDTH,
|
|
menuHeight: CHAT_SELECTION_MENU_HEIGHT,
|
|
offset: CHAT_SELECTION_MENU_OFFSET,
|
|
fallbackPointer: pointer,
|
|
selectionFocus: { node: selection.focusNode, offset: selection.focusOffset },
|
|
})
|
|
}
|
|
|
|
function getChatSelectionFromContainer(
|
|
root: HTMLElement | null,
|
|
pointer: SelectionPointer,
|
|
): ChatSelectionState | null {
|
|
if (!root) return null
|
|
const selection = window.getSelection()
|
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null
|
|
|
|
const range = selection.getRangeAt(0)
|
|
const startElement = getElementForNode(range.startContainer)
|
|
const endElement = getElementForNode(range.endContainer)
|
|
if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) {
|
|
return null
|
|
}
|
|
|
|
const text = selection.toString().trim()
|
|
if (!text) return null
|
|
|
|
return {
|
|
...getChatSelectionPosition(range, root, selection, pointer),
|
|
text,
|
|
}
|
|
}
|
|
|
|
function getSelectionPointer(event: SelectionPointer): SelectionPointer {
|
|
return {
|
|
clientX: event.clientX,
|
|
clientY: event.clientY,
|
|
}
|
|
}
|
|
|
|
function isPrimarySelectionPointer(event: Pick<PointerEvent, 'button' | 'ctrlKey' | 'pointerType'>) {
|
|
return event.button === 0 && !(event.pointerType === 'mouse' && event.ctrlKey)
|
|
}
|
|
|
|
function isKeyboardSelectionKey(event: KeyboardEvent) {
|
|
return event.shiftKey && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)
|
|
}
|
|
|
|
function ChatSelectionMenu({
|
|
selection,
|
|
onAdd,
|
|
onSideChat,
|
|
popoverRef,
|
|
}: {
|
|
selection: ChatSelectionState | null
|
|
onAdd: () => void
|
|
onSideChat: () => void
|
|
popoverRef: { current: HTMLDivElement | null }
|
|
}) {
|
|
const t = useTranslation()
|
|
if (!selection) return null
|
|
return createPortal(
|
|
<div ref={popoverRef} role="toolbar" aria-label={t('chat.selectionActions')}
|
|
onMouseDown={event => { if (event.button === 0 && !event.ctrlKey) event.preventDefault() }}
|
|
className="fixed z-[var(--z-popover)] inline-flex max-w-[calc(100vw-24px)] items-center rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-overlay)]"
|
|
style={{ left: selection.x, top: selection.y }}>
|
|
<Button variant="ghost" onClick={onAdd}>{t('chat.addSelectionToChat')}</Button>
|
|
<Button variant="ghost" onClick={onSideChat}>{t('chat.askSelectionInSideChat')}</Button>
|
|
</div>, document.body,
|
|
)
|
|
}
|
|
|
|
function getCompactSummaryTitle(message: CompactSummaryEvent, t: ReturnType<typeof useTranslation>) {
|
|
if (message.trigger === 'auto') return t('chat.compactSummary.autoTitle')
|
|
if (message.trigger === 'manual') return t('chat.compactSummary.manualTitle')
|
|
if (!message.title || message.title === 'Context compacted' || message.title === 'Conversation compacted') {
|
|
return t('chat.compactSummary.title')
|
|
}
|
|
return message.title
|
|
}
|
|
|
|
function CompactStatusDivider({ message, state }: { message?: CompactSummaryEvent; state: 'compacting' | 'complete' }) {
|
|
const t = useTranslation()
|
|
const [expanded, setExpanded] = useState(false)
|
|
const hasSummary = Boolean(message?.summary?.trim())
|
|
const meta = [
|
|
message?.trigger ? t(`chat.compactSummary.trigger.${message.trigger}` as TranslationKey) : null,
|
|
typeof message?.preTokens === 'number'
|
|
? t('chat.compactSummary.tokens', { count: formatTokenCount(message.preTokens) })
|
|
: null,
|
|
typeof message?.messagesSummarized === 'number'
|
|
? t('chat.compactSummary.messages', { count: String(message.messagesSummarized) })
|
|
: null,
|
|
].filter((item): item is string => Boolean(item))
|
|
const hasDetails = hasSummary || meta.length > 0
|
|
const title = state === 'compacting'
|
|
? t('chat.compactSummary.compacting')
|
|
: message
|
|
? getCompactSummaryTitle(message, t)
|
|
: t('chat.compactSummary.title')
|
|
|
|
return (
|
|
<section data-testid="compact-status-divider" className="my-4 w-full px-1">
|
|
<div className="flex w-full items-center gap-3">
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
|
|
<button
|
|
type="button"
|
|
aria-expanded={hasDetails ? expanded : undefined}
|
|
onClick={() => hasDetails && setExpanded((value) => !value)}
|
|
disabled={!hasDetails}
|
|
className="group inline-flex min-h-8 max-w-[min(78vw,520px)] items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1 text-[13px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
|
|
>
|
|
{state === 'compacting' ? (
|
|
<LoaderCircle size={16} strokeWidth={2.1} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
) : (
|
|
<FileStack size={16} strokeWidth={2.05} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
)}
|
|
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">
|
|
{title}
|
|
</span>
|
|
</button>
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
|
|
</div>
|
|
{hasDetails && expanded && (
|
|
<div className="mx-auto mt-1.5 w-full max-w-[620px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2">
|
|
{meta.length > 0 && (
|
|
<div className="mb-1.5 flex flex-wrap gap-x-2 gap-y-1 text-[11px] font-medium text-[var(--color-text-tertiary)]">
|
|
{meta.map((item) => <span key={item}>{item}</span>)}
|
|
</div>
|
|
)}
|
|
{message?.summary && (
|
|
<div className="max-h-[220px] overflow-auto whitespace-pre-wrap break-words text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
|
{message.summary}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function GoalEventCard({ message }: { message: GoalEvent }) {
|
|
const t = useTranslation()
|
|
const [expanded, setExpanded] = useState(true)
|
|
const titleKey = `chat.goalEvent.${message.action === 'status' ? 'statusTitle' : message.action}` as TranslationKey
|
|
const title = t(titleKey) === titleKey ? t('chat.goalEvent.message') : t(titleKey)
|
|
const metaDetails = [
|
|
message.status ? t('chat.goalEvent.statusValue', { value: message.status }) : null,
|
|
message.budget ? t('chat.goalEvent.budget', { value: message.budget }) : null,
|
|
message.continuations ? t('chat.goalEvent.continuations', { value: message.continuations }) : null,
|
|
].filter((detail): detail is string => detail !== null)
|
|
|
|
return (
|
|
<div className="mb-2">
|
|
<div
|
|
data-testid="goal-event-card"
|
|
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
) : (
|
|
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
)}
|
|
<Target size={15} className="shrink-0 text-[var(--color-memory-accent)]" strokeWidth={2.25} aria-hidden="true" />
|
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
|
|
{title}
|
|
</span>
|
|
{message.status ? (
|
|
<span className="inline-flex shrink-0 items-center gap-1 text-[12px] text-[var(--color-text-tertiary)]">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-memory-accent)]" aria-hidden="true" />
|
|
{message.status}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
|
|
{expanded ? (
|
|
<div className="border-t border-[var(--color-border)] px-3 py-2.5">
|
|
<div className="space-y-1.5">
|
|
{message.objective ? (
|
|
<div className="line-clamp-2 rounded-[var(--radius-md)] px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
|
{t('chat.goalEvent.objective', { value: message.objective })}
|
|
</div>
|
|
) : message.message ? (
|
|
<div className="whitespace-pre-wrap rounded-[var(--radius-md)] px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
|
{message.message}
|
|
</div>
|
|
) : null}
|
|
{metaDetails.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-1.5 px-2 pt-0.5">
|
|
{metaDetails.map((detail) => (
|
|
<span
|
|
key={detail}
|
|
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]"
|
|
>
|
|
{detail}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function GoalContinuationDivider({ message }: { message: GoalEvent }) {
|
|
const t = useTranslation()
|
|
const reason = message.message?.replace(/^Goal continuing:\s*/i, '').trim()
|
|
|
|
return (
|
|
<section data-testid="goal-continuation-divider" className="my-4 w-full px-1">
|
|
<div className="flex w-full items-center gap-3">
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
|
|
<div className="inline-flex min-h-8 max-w-[min(78vw,620px)] items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1 text-[13px] font-medium text-[var(--color-text-secondary)]">
|
|
<Target size={16} strokeWidth={2.1} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
|
|
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
|
|
{t('chat.goalEvent.continuing')}
|
|
</span>
|
|
{reason ? (
|
|
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]" title={reason}>
|
|
{reason}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) {
|
|
const t = useTranslation()
|
|
const { task } = message
|
|
const isRunning = task.status === 'running'
|
|
const isFailed = task.status === 'failed'
|
|
const isStopped = task.status === 'stopped'
|
|
const duration = formatDurationMs(task.usage?.durationMs, t)
|
|
const detail = task.summary || task.lastToolName || task.description || task.outputFile || task.taskId
|
|
const label = getBackgroundTaskLabel(task.taskType, t)
|
|
|
|
return (
|
|
<div className="mb-2">
|
|
<div
|
|
data-testid="background-task-event-card"
|
|
data-status={task.status}
|
|
className="flex min-w-0 items-start gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
|
|
>
|
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
|
{isRunning ? (
|
|
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-brand)]" aria-hidden="true" />
|
|
) : isFailed ? (
|
|
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
|
|
) : isStopped ? (
|
|
<CircleStop size={15} strokeWidth={2.25} className="text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
) : (
|
|
<CheckCircle2 size={15} strokeWidth={2.25} className="text-[var(--color-success)]" aria-hidden="true" />
|
|
)}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Bot size={14} strokeWidth={2.25} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
|
|
<span className="shrink-0 text-[12px] font-medium text-[var(--color-text-primary)]">
|
|
{label}
|
|
</span>
|
|
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
|
|
{t(`chat.backgroundAgents.status.${task.status}`)}
|
|
</span>
|
|
{task.usage?.totalTokens ? (
|
|
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
|
|
{t('chat.backgroundAgents.tokens', { count: formatTokenCount(task.usage.totalTokens) })}
|
|
</span>
|
|
) : null}
|
|
{duration ? (
|
|
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
|
|
{duration}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-0.5 truncate text-[12px] leading-5 text-[var(--color-text-secondary)]">
|
|
{detail}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function isAgentBackgroundTaskMessage(message: UIMessage): boolean {
|
|
if (message.type !== 'background_task') return false
|
|
if (
|
|
message.task.taskType === 'local_agent' ||
|
|
message.task.taskType === 'remote_agent' ||
|
|
message.task.taskType === 'dream'
|
|
) {
|
|
return true
|
|
}
|
|
return /^Agent (?:(?:"[^"]+" )?(completed|was stopped)|(?:"[^"]+" )?failed(?::|$))/.test(
|
|
message.task.summary ?? '',
|
|
)
|
|
}
|
|
|
|
function getBackgroundTaskLabel(
|
|
taskType: string | undefined,
|
|
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
if (taskType === 'local_bash') return t('chat.backgroundTasks.command')
|
|
if (taskType === 'local_workflow') return t('chat.backgroundTasks.workflow')
|
|
return t('chat.backgroundTasks.task')
|
|
}
|
|
|
|
function SelectableChatMessage({
|
|
sessionId,
|
|
messageId,
|
|
role,
|
|
content,
|
|
children,
|
|
}: {
|
|
sessionId?: string | null
|
|
messageId: string
|
|
role: ChatMessageRole
|
|
content: string
|
|
children: ReactNode
|
|
}) {
|
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
const selectionMenuRef = useRef<HTMLDivElement>(null)
|
|
const lastSelectionPointerRef = useRef<SelectionPointer | null>(null)
|
|
const selectionGestureEpochRef = useRef(0)
|
|
const selectionStartedInsideRef = useRef(false)
|
|
const selectionUpdateAuthorizedRef = useRef(false)
|
|
const selectionUpdateFrameRef = useRef<number | null>(null)
|
|
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
|
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
|
const t = useTranslation()
|
|
const sourceName = role === 'assistant'
|
|
? t('chat.assistantMessageReference')
|
|
: t('chat.userMessageReference')
|
|
|
|
const cancelPendingSelectionMenuUpdate = useCallback(() => {
|
|
selectionGestureEpochRef.current += 1
|
|
if (selectionUpdateFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
|
|
selectionUpdateFrameRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
cancelPendingSelectionMenuUpdate()
|
|
setSelectionMenu(null)
|
|
lastSelectionPointerRef.current = null
|
|
selectionStartedInsideRef.current = false
|
|
selectionUpdateAuthorizedRef.current = false
|
|
}, [cancelPendingSelectionMenuUpdate, content, messageId])
|
|
|
|
const dismissSelectionMenu = useCallback(() => {
|
|
setSelectionMenu(null)
|
|
}, [])
|
|
|
|
const queueSelectionMenuUpdate = useCallback((pointer?: SelectionPointer) => {
|
|
if (pointer) lastSelectionPointerRef.current = pointer
|
|
|
|
if (selectionUpdateFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
|
|
}
|
|
|
|
const gestureEpoch = selectionGestureEpochRef.current
|
|
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
|
|
if (gestureEpoch !== selectionGestureEpochRef.current) {
|
|
selectionUpdateFrameRef.current = null
|
|
return
|
|
}
|
|
selectionUpdateFrameRef.current = window.requestAnimationFrame(() => {
|
|
selectionUpdateFrameRef.current = null
|
|
if (gestureEpoch !== selectionGestureEpochRef.current || !selectionUpdateAuthorizedRef.current) return
|
|
|
|
const root = rootRef.current
|
|
const rootRect = root?.getBoundingClientRect()
|
|
const fallbackPointer = lastSelectionPointerRef.current ?? {
|
|
clientX: (rootRect?.left ?? 0) + 24,
|
|
clientY: (rootRect?.top ?? 0) + 24,
|
|
}
|
|
setSelectionMenu(getChatSelectionFromContainer(root, fallbackPointer))
|
|
})
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (selectionUpdateFrameRef.current !== null) {
|
|
window.cancelAnimationFrame(selectionUpdateFrameRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
cancelPendingSelectionMenuUpdate()
|
|
const root = rootRef.current
|
|
const target = event.target
|
|
const startsInside = isPrimarySelectionPointer(event)
|
|
&& target instanceof Node
|
|
&& Boolean(root?.contains(target))
|
|
selectionStartedInsideRef.current = startsInside
|
|
selectionUpdateAuthorizedRef.current = startsInside
|
|
if (startsInside) lastSelectionPointerRef.current = getSelectionPointer(event)
|
|
}
|
|
|
|
const handlePointerUp = (event: PointerEvent) => {
|
|
if (!selectionStartedInsideRef.current || !isPrimarySelectionPointer(event)) return
|
|
selectionStartedInsideRef.current = false
|
|
selectionUpdateAuthorizedRef.current = true
|
|
queueSelectionMenuUpdate(getSelectionPointer(event))
|
|
}
|
|
|
|
const handleSelectionChange = () => {
|
|
if (selectionStartedInsideRef.current || !selectionUpdateAuthorizedRef.current) return
|
|
queueSelectionMenuUpdate()
|
|
}
|
|
|
|
const handleKeyUp = (event: KeyboardEvent) => {
|
|
if (!isKeyboardSelectionKey(event)) return
|
|
cancelPendingSelectionMenuUpdate()
|
|
lastSelectionPointerRef.current = null
|
|
selectionStartedInsideRef.current = false
|
|
selectionUpdateAuthorizedRef.current = true
|
|
queueSelectionMenuUpdate()
|
|
}
|
|
|
|
const handleContextMenu = () => {
|
|
cancelPendingSelectionMenuUpdate()
|
|
selectionStartedInsideRef.current = false
|
|
selectionUpdateAuthorizedRef.current = false
|
|
setSelectionMenu(null)
|
|
}
|
|
|
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
|
document.addEventListener('pointerup', handlePointerUp, true)
|
|
document.addEventListener('selectionchange', handleSelectionChange)
|
|
document.addEventListener('keyup', handleKeyUp, true)
|
|
document.addEventListener('contextmenu', handleContextMenu, true)
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handlePointerDown, true)
|
|
document.removeEventListener('pointerup', handlePointerUp, true)
|
|
document.removeEventListener('selectionchange', handleSelectionChange)
|
|
document.removeEventListener('keyup', handleKeyUp, true)
|
|
document.removeEventListener('contextmenu', handleContextMenu, true)
|
|
}
|
|
}, [cancelPendingSelectionMenuUpdate, queueSelectionMenuUpdate])
|
|
|
|
useSelectionPopoverDismiss({
|
|
active: Boolean(selectionMenu),
|
|
popoverRef: selectionMenuRef,
|
|
onDismiss: dismissSelectionMenu,
|
|
})
|
|
|
|
const addCurrentSelectionToChat = useCallback(() => {
|
|
if (!sessionId || !selectionMenu) return
|
|
addReference(sessionId, {
|
|
kind: 'chat-selection',
|
|
path: `chat://${role}/${messageId}`,
|
|
name: sourceName,
|
|
quote: selectionMenu.text,
|
|
sourceRole: role,
|
|
messageId,
|
|
})
|
|
setSelectionMenu(null)
|
|
clearWindowSelection()
|
|
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
|
|
|
|
const askSelectionInSideChat = useCallback(() => {
|
|
if (!sessionId || !selectionMenu) return
|
|
void openSideChat(sessionId, { reference: {
|
|
kind: 'chat-selection', path: `chat://${role}/${messageId}`,
|
|
name: sourceName, quote: selectionMenu.text, sourceRole: role, messageId,
|
|
} })
|
|
setSelectionMenu(null)
|
|
clearWindowSelection()
|
|
}, [messageId, role, selectionMenu, sessionId, sourceName])
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
data-chat-selectable-message={role}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') setSelectionMenu(null)
|
|
}}
|
|
>
|
|
{children}
|
|
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} onSideChat={askSelectionInSideChat} popoverRef={selectionMenuRef} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function appendChildToolCall(
|
|
childToolCallsByParent: Map<string, ToolCall[]>,
|
|
parentToolUseId: string,
|
|
toolCall: ToolCall,
|
|
) {
|
|
const siblings = childToolCallsByParent.get(parentToolUseId)
|
|
if (siblings) {
|
|
siblings.push(toolCall)
|
|
} else {
|
|
childToolCallsByParent.set(parentToolUseId, [toolCall])
|
|
}
|
|
}
|
|
|
|
function hasTeamMessageRouting(value: unknown): boolean {
|
|
if (typeof value === 'string') {
|
|
try {
|
|
return hasTeamMessageRouting(JSON.parse(value))
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
if (Array.isArray(value)) return value.some(hasTeamMessageRouting)
|
|
if (!value || typeof value !== 'object') return false
|
|
const record = value as Record<string, unknown>
|
|
const routing = record.routing
|
|
if (routing && typeof routing === 'object') {
|
|
const route = routing as Record<string, unknown>
|
|
if (typeof route.sender === 'string' && typeof route.target === 'string') return true
|
|
}
|
|
return 'content' in record && hasTeamMessageRouting(record.content)
|
|
}
|
|
|
|
function getSendMessageTarget(value: unknown): string | null {
|
|
if (typeof value === 'string') {
|
|
try {
|
|
return getSendMessageTarget(JSON.parse(value))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
const target = (value as Record<string, unknown>).to
|
|
return typeof target === 'string' ? target : null
|
|
}
|
|
|
|
function isTeamCoordinationSendMessage(
|
|
input: unknown,
|
|
result: unknown,
|
|
teamMemberNames: ReadonlySet<string> | undefined,
|
|
allowRosterFallback: boolean,
|
|
): boolean {
|
|
if (hasTeamMessageRouting(result)) return true
|
|
if (!allowRosterFallback) return false
|
|
const target = getSendMessageTarget(input)
|
|
return target === '*' || Boolean(target && teamMemberNames?.has(target))
|
|
}
|
|
|
|
function parseRecordValue(value: unknown): Record<string, unknown> | null {
|
|
if (isRecordValue(value)) return value
|
|
if (typeof value !== 'string') return null
|
|
const text = value.trim()
|
|
if (!text.startsWith('{')) return null
|
|
try {
|
|
const parsed = JSON.parse(text) as unknown
|
|
return isRecordValue(parsed) ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function nestedStringField(value: unknown, field: string): string {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const nested = nestedStringField(item, field)
|
|
if (nested) return nested
|
|
}
|
|
return ''
|
|
}
|
|
const record = parseRecordValue(value)
|
|
if (!record) return ''
|
|
const direct = record[field]
|
|
if (typeof direct === 'string' && direct.trim()) return direct.trim()
|
|
for (const child of [record.content, record.text]) {
|
|
const nested = nestedStringField(child, field)
|
|
if (nested) return nested
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function explicitSuccessFlag(value: unknown): boolean | undefined {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
const nested = explicitSuccessFlag(item)
|
|
if (nested !== undefined) return nested
|
|
}
|
|
return undefined
|
|
}
|
|
const record = parseRecordValue(value)
|
|
if (!record) return undefined
|
|
if (typeof record.success === 'boolean') return record.success
|
|
if ('content' in record) return explicitSuccessFlag(record.content)
|
|
if ('text' in record) return explicitSuccessFlag(record.text)
|
|
return undefined
|
|
}
|
|
|
|
function canSummarizeCoordinationResult(result: ToolResult | undefined): boolean {
|
|
return !result || (!result.isError && explicitSuccessFlag(result.content) !== false)
|
|
}
|
|
|
|
function lifecycleToolSucceeded(result: ToolResult | undefined): boolean {
|
|
return Boolean(result) && canSummarizeCoordinationResult(result)
|
|
}
|
|
|
|
function teamNameFromCreate(toolCall: ToolCall, result: ToolResult | undefined): string {
|
|
const input = parseRecordValue(toolCall.input)
|
|
// TeamCreate can uniquify a requested name. The successful result owns the
|
|
// durable identity that the workbench will expose.
|
|
for (const value of [nestedStringField(result?.content, 'team_name'), input?.team_name]) {
|
|
if (typeof value === 'string' && value.trim()) return value.trim()
|
|
}
|
|
return 'Agent Teams'
|
|
}
|
|
|
|
function isExplicitTeamAgent(
|
|
input: unknown,
|
|
result: ToolResult | undefined,
|
|
teamActive: boolean,
|
|
): boolean {
|
|
const inputRecord = parseRecordValue(input)
|
|
const inputTeamName = inputRecord?.team_name
|
|
if (typeof inputTeamName === 'string' && inputTeamName.trim()) return true
|
|
const inputName = inputRecord?.name
|
|
if (teamActive && typeof inputName === 'string' && inputName.trim()) return true
|
|
|
|
if (nestedStringField(result?.content, 'status') === 'teammate_spawned') return true
|
|
if (typeof result?.content !== 'string') return false
|
|
return /^\s*team_name:\s*\S+/m.test(result.content) && /^\s*name:\s*\S+/m.test(result.content)
|
|
}
|
|
|
|
export function buildRenderModel(
|
|
messages: UIMessage[],
|
|
activeAskUserQuestionToolUseId?: string | null,
|
|
options: {
|
|
hideTeamCoordinationTools?: boolean
|
|
teamMemberNames?: ReadonlySet<string>
|
|
teamTaskWindows?: Array<{ startedAt: number; endedAt?: number }>
|
|
teamName?: string
|
|
teamStartedAt?: number
|
|
} = {},
|
|
): RenderModel {
|
|
const items: RenderItem[] = []
|
|
const toolResultMap = new Map<string, ToolResult>()
|
|
const childToolCallsByParent = new Map<string, ToolCall[]>()
|
|
const toolUseIds = new Set<string>()
|
|
const lastUnresolvedAskUserQuestionIndexByToolUseId = new Map<string, number>()
|
|
let lastUnresolvedAskUserQuestionIndex: number | null = null
|
|
let transcriptTeamCursor = EMPTY_TEAM_LIFECYCLE_CURSOR
|
|
let activeTeamCard: Extract<RenderItem, { kind: 'team_card' }> | undefined
|
|
let pendingSteps: ActivityStep[] = []
|
|
let pendingToolCount = 0
|
|
let pendingAgentCount = 0
|
|
|
|
const flushGroup = () => {
|
|
if (pendingSteps.length === 0) return
|
|
const steps = pendingSteps
|
|
const toolCount = pendingToolCount
|
|
pendingSteps = []
|
|
pendingToolCount = 0
|
|
pendingAgentCount = 0
|
|
|
|
if (toolCount === 0) {
|
|
// A run of pure reasoning has nothing to summarize into a header, so the
|
|
// thinking blocks keep their standalone inline form.
|
|
for (const step of steps) {
|
|
if (step.kind === 'thinking') items.push({ kind: 'message', message: step.message })
|
|
}
|
|
return
|
|
}
|
|
|
|
const toolCalls = steps.flatMap((step) => (step.kind === 'tool' ? [step.toolCall] : []))
|
|
items.push({
|
|
kind: 'tool_group',
|
|
toolCalls,
|
|
steps,
|
|
// Keyed off the first tool call, not the first step: a thinking block that
|
|
// later gains tools must not remount the group under the reader.
|
|
id: `group-${toolCalls[0]!.id}`,
|
|
})
|
|
}
|
|
const appendRootToolCall = (toolCall: ToolCall) => {
|
|
const nextIsAgent = toolCall.toolName === 'Agent'
|
|
// Agent runs render as their own dispatch cards, so they never mix with
|
|
// ordinary steps — including the thinking that preceded the dispatch.
|
|
const pendingIsAgentGroup = pendingToolCount > 0 && pendingAgentCount === pendingToolCount
|
|
const pendingBlocksAgent = nextIsAgent && pendingSteps.length > pendingAgentCount
|
|
|
|
if (pendingBlocksAgent || (pendingToolCount > 0 && pendingIsAgentGroup !== nextIsAgent)) {
|
|
flushGroup()
|
|
}
|
|
pendingSteps.push({ kind: 'tool', toolCall })
|
|
pendingToolCount += 1
|
|
if (nextIsAgent) pendingAgentCount += 1
|
|
}
|
|
const appendThinking = (message: Extract<UIMessage, { type: 'thinking' }>) => {
|
|
if (pendingToolCount > 0 && pendingAgentCount === pendingToolCount) flushGroup()
|
|
pendingSteps.push({ kind: 'thinking', message })
|
|
}
|
|
const ensureTeamCardForCoordination = (
|
|
message: ToolCall,
|
|
result: ToolResult | undefined,
|
|
) => {
|
|
const input = parseRecordValue(message.input)
|
|
const outputTeamName = nestedStringField(result?.content, 'team_name')
|
|
const inputTeamName = typeof input?.team_name === 'string'
|
|
? input.team_name.trim()
|
|
: ''
|
|
const explicitTeamName = outputTeamName || inputTeamName
|
|
if (
|
|
activeTeamCard &&
|
|
(!explicitTeamName || activeTeamCard.teamName === explicitTeamName)
|
|
) return activeTeamCard
|
|
if (activeTeamCard) activeTeamCard = undefined
|
|
|
|
const scopedTeamName = options.teamName?.trim() || ''
|
|
const teamName = explicitTeamName || scopedTeamName
|
|
if (!teamName) return undefined
|
|
const usesDurableScope = !explicitTeamName || explicitTeamName === scopedTeamName
|
|
|
|
flushGroup()
|
|
activeTeamCard = {
|
|
kind: 'team_card',
|
|
id: `team-card-scope-${message.id}`,
|
|
teamName,
|
|
startedAt: usesDurableScope
|
|
? options.teamStartedAt ?? message.timestamp
|
|
: message.timestamp,
|
|
coordinationToolCalls: [],
|
|
}
|
|
items.push(activeTeamCard)
|
|
return activeTeamCard
|
|
}
|
|
|
|
for (const msg of messages) {
|
|
if (msg.type === 'tool_use') {
|
|
toolUseIds.add(msg.toolUseId)
|
|
}
|
|
if (msg.type === 'tool_result') {
|
|
toolResultMap.set(msg.toolUseId, msg)
|
|
}
|
|
}
|
|
const hasTeamLifecycleEvidence = Boolean(options.teamTaskWindows?.length) || messages.some((msg) => (
|
|
msg.type === 'tool_use' &&
|
|
(msg.toolName === 'TeamCreate' || msg.toolName === 'TeamDelete') &&
|
|
lifecycleToolSucceeded(toolResultMap.get(msg.toolUseId))
|
|
))
|
|
// Two passes on purpose: a question is superseded by a user message *after*
|
|
// it, so the last user message has to be known before judging any of them.
|
|
let lastUserTextIndex = -1
|
|
messages.forEach((msg, index) => {
|
|
if (msg.type === 'user_text') lastUserTextIndex = index
|
|
})
|
|
const supersededAskUserQuestionIds = new Set<string>()
|
|
messages.forEach((msg, index) => {
|
|
if (
|
|
msg.type === 'tool_use' &&
|
|
msg.toolName === 'AskUserQuestion' &&
|
|
!toolResultMap.has(msg.toolUseId)
|
|
) {
|
|
lastUnresolvedAskUserQuestionIndexByToolUseId.set(msg.toolUseId, index)
|
|
lastUnresolvedAskUserQuestionIndex = index
|
|
if (lastUserTextIndex > index) supersededAskUserQuestionIds.add(msg.toolUseId)
|
|
}
|
|
})
|
|
|
|
for (const msg of messages) {
|
|
if (msg.type === 'assistant_text' && !msg.content.trim()) {
|
|
continue
|
|
}
|
|
if (isAgentBackgroundTaskMessage(msg)) {
|
|
continue
|
|
}
|
|
|
|
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
|
|
continue
|
|
}
|
|
if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
|
|
continue
|
|
}
|
|
|
|
if (msg.type === 'tool_use') {
|
|
if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
|
|
flushGroup()
|
|
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
|
|
continue
|
|
}
|
|
const toolResult = toolResultMap.get(msg.toolUseId)
|
|
let summarizedTeamDelete = false
|
|
if (options.hideTeamCoordinationTools) {
|
|
if (msg.toolName === 'TeamCreate' && lifecycleToolSucceeded(toolResult)) {
|
|
transcriptTeamCursor = updateTeamLifecycleCursor(
|
|
true,
|
|
msg.timestamp,
|
|
)
|
|
} else if (msg.toolName === 'TeamDelete' && lifecycleToolSucceeded(toolResult)) {
|
|
transcriptTeamCursor = updateTeamLifecycleCursor(
|
|
false,
|
|
msg.timestamp,
|
|
)
|
|
const deletedTeamCard = ensureTeamCardForCoordination(msg, toolResult)
|
|
if (deletedTeamCard) {
|
|
deletedTeamCard.endedAt = msg.timestamp
|
|
deletedTeamCard.coordinationToolCalls.push(msg)
|
|
summarizedTeamDelete = true
|
|
}
|
|
// A later lifecycle can be present only in the durable workbench
|
|
// window after transcript compaction. Do not attach that run's
|
|
// coordination audit to the card from the lifecycle just deleted.
|
|
activeTeamCard = undefined
|
|
}
|
|
|
|
const isTeamScopedAtMessage = isTeamLifecycleScopedAt(
|
|
msg.timestamp,
|
|
transcriptTeamCursor,
|
|
options.teamTaskWindows,
|
|
)
|
|
const isTeamTask =
|
|
(msg.toolName === 'TaskCreate' || msg.toolName === 'TaskUpdate') &&
|
|
isTeamScopedAtMessage
|
|
const explicitTeamAgent = msg.toolName === 'Agent' &&
|
|
isExplicitTeamAgent(msg.input, toolResult, isTeamScopedAtMessage)
|
|
const ambiguousTeamAgent = msg.toolName === 'Agent' &&
|
|
isTeamScopedAtMessage &&
|
|
!explicitTeamAgent &&
|
|
msg.isPending === true
|
|
const isTeamAgent = msg.toolName === 'Agent' && (
|
|
explicitTeamAgent
|
|
)
|
|
const isTeamMessage = msg.toolName === 'SendMessage' && (
|
|
isTeamScopedAtMessage ||
|
|
isTeamCoordinationSendMessage(
|
|
msg.input,
|
|
toolResult?.content,
|
|
options.teamMemberNames,
|
|
!hasTeamLifecycleEvidence,
|
|
)
|
|
)
|
|
|
|
// During an active Team lifecycle, a streamed Agent without
|
|
// teammate identity is not yet classifiable: it can settle as a direct
|
|
// SubAgent if `name` stays absent, or as a teammate once that identity
|
|
// arrives. Keep it out of both projections during that partial state.
|
|
if (ambiguousTeamAgent) continue
|
|
|
|
// These records remain untouched in `messages` and `toolResultMap`.
|
|
// The lead transcript projects them through the inline team card while
|
|
// the workbench owns the roster, DAG, and communication presentation.
|
|
// A failed coordination call stays visible because it changes what the
|
|
// turn means and is not represented by successful workbench state.
|
|
if (
|
|
(isTeamTask || isTeamAgent || isTeamMessage) &&
|
|
canSummarizeCoordinationResult(toolResult)
|
|
) {
|
|
const teamCard = ensureTeamCardForCoordination(msg, toolResult)
|
|
if (teamCard) {
|
|
teamCard.coordinationToolCalls.push(msg)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if (summarizedTeamDelete) continue
|
|
// The raw TeamCreate call and its JSON result say nothing a reader can
|
|
// use; the team card in its place links to the workbench that does.
|
|
if (
|
|
options.hideTeamCoordinationTools &&
|
|
msg.toolName === 'TeamCreate' &&
|
|
canSummarizeCoordinationResult(toolResult)
|
|
) {
|
|
flushGroup()
|
|
activeTeamCard = {
|
|
kind: 'team_card',
|
|
id: `team-card-${msg.id}`,
|
|
teamName: teamNameFromCreate(msg, toolResult),
|
|
startedAt: msg.timestamp,
|
|
coordinationToolCalls: [],
|
|
}
|
|
items.push(activeTeamCard)
|
|
continue
|
|
}
|
|
if (msg.toolName === 'AskUserQuestion') {
|
|
const isResolved = toolResultMap.has(msg.toolUseId)
|
|
const lastUnresolvedIndex = lastUnresolvedAskUserQuestionIndexByToolUseId.get(msg.toolUseId)
|
|
if (!isResolved && lastUnresolvedIndex !== undefined && messages[lastUnresolvedIndex] !== msg) {
|
|
continue
|
|
}
|
|
if (
|
|
!isResolved &&
|
|
activeAskUserQuestionToolUseId &&
|
|
msg.toolUseId !== activeAskUserQuestionToolUseId
|
|
) {
|
|
continue
|
|
}
|
|
if (
|
|
!isResolved &&
|
|
!activeAskUserQuestionToolUseId &&
|
|
lastUnresolvedAskUserQuestionIndex !== null &&
|
|
messages[lastUnresolvedAskUserQuestionIndex] !== msg
|
|
) {
|
|
continue
|
|
}
|
|
flushGroup()
|
|
items.push({ kind: 'message', message: msg })
|
|
} else {
|
|
appendRootToolCall(msg)
|
|
}
|
|
} else if (msg.type === 'thinking') {
|
|
appendThinking(msg)
|
|
} else if (msg.type === 'background_task' && msg.task.status === 'completed') {
|
|
// The activity panel already lists every background task — agent-like ones
|
|
// under SubAgents, the rest under Background Tasks — with live status and a
|
|
// way into the full run. A card here repeating a finished one just buries
|
|
// the conversation under status reports, and a long team session emits
|
|
// dozens. Anything that did NOT finish cleanly still gets a card: a failure
|
|
// or a stop changes what the turn means, and the panel can be closed.
|
|
continue
|
|
} else {
|
|
flushGroup()
|
|
items.push({ kind: 'message', message: msg })
|
|
}
|
|
}
|
|
|
|
flushGroup()
|
|
return { renderItems: items, toolResultMap, childToolCallsByParent, supersededAskUserQuestionIds }
|
|
}
|
|
|
|
function coordinationToolSummary(toolCall: ToolCall): string | null {
|
|
const input = parseRecordValue(toolCall.input)
|
|
if (!input) return null
|
|
const values = toolCall.toolName === 'TaskCreate'
|
|
? [input.subject]
|
|
: toolCall.toolName === 'TaskUpdate'
|
|
? [input.taskId, input.status, input.owner]
|
|
: toolCall.toolName === 'Agent'
|
|
? [input.name, input.description]
|
|
: toolCall.toolName === 'SendMessage'
|
|
? [input.to, input.message]
|
|
: []
|
|
const summary = values
|
|
.filter((value): value is string => typeof value === 'string' && Boolean(value.trim()))
|
|
.join(' · ')
|
|
return summary || null
|
|
}
|
|
|
|
function TeamCoordinationAudit({ toolCalls }: { toolCalls: ToolCall[] }) {
|
|
const t = useTranslation()
|
|
if (toolCalls.length === 0) return null
|
|
|
|
return (
|
|
<details
|
|
data-testid="agent-teams-coordination-audit"
|
|
className="mx-2 border-x border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
|
|
>
|
|
<summary className="cursor-pointer select-none font-medium text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]">
|
|
{t('agentTeams.inline.coordination', { count: toolCalls.length })}
|
|
</summary>
|
|
<ol className="mt-2 space-y-1 border-t border-[var(--color-border)] pt-2">
|
|
{toolCalls.map((toolCall) => {
|
|
const summary = coordinationToolSummary(toolCall)
|
|
return (
|
|
<li key={toolCall.toolUseId} className="flex min-w-0 items-start gap-2">
|
|
<code className="shrink-0 font-mono text-[10px] font-semibold text-[var(--color-text-tertiary)]">
|
|
{toolCall.toolName}
|
|
</code>
|
|
{summary ? <span className="min-w-0 break-words">{summary}</span> : null}
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
function teamTimestamp(value: string | number | undefined): number | undefined {
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
|
|
if (!value) return undefined
|
|
const numeric = Number(value)
|
|
if (Number.isFinite(numeric)) return numeric
|
|
const parsed = Date.parse(value)
|
|
return Number.isFinite(parsed) ? parsed : undefined
|
|
}
|
|
|
|
function snapshotForTeamCard(
|
|
snapshot: TeamWorkbenchSnapshot | undefined,
|
|
item: Extract<RenderItem, { kind: 'team_card' }>,
|
|
): TeamWorkbenchSnapshot | undefined {
|
|
if (!snapshot || snapshot.team.name !== item.teamName) return undefined
|
|
const startedAt = teamTimestamp(snapshot.team.createdAt)
|
|
if (startedAt === undefined) return snapshot
|
|
if (item.endedAt !== undefined && item.endedAt < startedAt) return undefined
|
|
|
|
// The TeamCreate tool and server lifecycle event are emitted by separate
|
|
// transports, so allow a small clock/order gap while still refusing to bind
|
|
// an older card to a newer incarnation that reused the same team name.
|
|
return Math.abs(item.startedAt - startedAt) <= 5_000 ? snapshot : undefined
|
|
}
|
|
|
|
function isTurnResponseMessage(message: UIMessage) {
|
|
return (
|
|
message.type === 'assistant_text' ||
|
|
message.type === 'tool_use' ||
|
|
message.type === 'tool_result' ||
|
|
(message.type === 'background_task' && !isAgentBackgroundTaskMessage(message)) ||
|
|
message.type === 'error' ||
|
|
message.type === 'task_summary'
|
|
)
|
|
}
|
|
|
|
function getBranchableMessageTargets(messages: UIMessage[]): Map<string, BranchableMessageTarget> {
|
|
const branchableTargets = new Map<string, BranchableMessageTarget>()
|
|
let currentTurnCandidates: Array<Extract<UIMessage, { type: 'user_text' | 'assistant_text' }>> = []
|
|
let hasResponseForCurrentTurn = false
|
|
|
|
const markCurrentTurnBranchable = () => {
|
|
if (!hasResponseForCurrentTurn) return
|
|
for (const candidate of currentTurnCandidates) {
|
|
if (!candidate.transcriptMessageId) continue
|
|
branchableTargets.set(candidate.id, {
|
|
uiMessageId: candidate.id,
|
|
transcriptMessageId: candidate.transcriptMessageId,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (const message of messages) {
|
|
if (message.type === 'user_text') {
|
|
markCurrentTurnBranchable()
|
|
currentTurnCandidates = []
|
|
hasResponseForCurrentTurn = false
|
|
if (!message.pending && message.transcriptMessageId) {
|
|
currentTurnCandidates = [message]
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (currentTurnCandidates.length === 0) continue
|
|
|
|
if (isTurnResponseMessage(message)) {
|
|
hasResponseForCurrentTurn = true
|
|
}
|
|
|
|
if (message.type === 'assistant_text' && message.transcriptMessageId) {
|
|
currentTurnCandidates.push(message)
|
|
}
|
|
}
|
|
|
|
markCurrentTurnBranchable()
|
|
return branchableTargets
|
|
}
|
|
|
|
export function getCompletedTurnTargets(messages: UIMessage[]): RewindTurnTarget[] {
|
|
let userMessageIndex = -1
|
|
const completedTurns: RewindTurnTarget[] = []
|
|
let currentTarget: RewindTurnTarget | null = null
|
|
let hasResponseForCurrentTarget = false
|
|
|
|
for (const message of messages) {
|
|
if (message.type === 'user_text' && !message.pending) {
|
|
if (currentTarget && hasResponseForCurrentTarget) {
|
|
completedTurns.push(currentTarget)
|
|
}
|
|
userMessageIndex += 1
|
|
currentTarget = {
|
|
messageId: message.id,
|
|
userMessageIndex,
|
|
content: message.content,
|
|
expectedContent: message.modelContent ?? message.content,
|
|
attachments: message.attachments,
|
|
}
|
|
hasResponseForCurrentTarget = false
|
|
continue
|
|
}
|
|
|
|
if (currentTarget && isTurnResponseMessage(message)) {
|
|
hasResponseForCurrentTarget = true
|
|
}
|
|
}
|
|
|
|
if (currentTarget && hasResponseForCurrentTarget) {
|
|
completedTurns.push(currentTarget)
|
|
}
|
|
|
|
return completedTurns
|
|
}
|
|
|
|
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
|
|
const completedTurns = getCompletedTurnTargets(messages)
|
|
return completedTurns.length > 0 ? completedTurns[completedTurns.length - 1] ?? null : null
|
|
}
|
|
|
|
function buildTurnCardInsertionMap(
|
|
renderItems: RenderItem[],
|
|
turnChangeCards: TurnChangeCardModel[],
|
|
) {
|
|
const lastResponseIndexByTurnId = new Map<string, number>()
|
|
const userIndexByTurnId = new Map<string, number>()
|
|
let activeTurnId: string | null = null
|
|
|
|
renderItems.forEach((item, index) => {
|
|
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
|
|
activeTurnId = item.message.id
|
|
userIndexByTurnId.set(activeTurnId, index)
|
|
return
|
|
}
|
|
|
|
if (activeTurnId) {
|
|
lastResponseIndexByTurnId.set(activeTurnId, index)
|
|
}
|
|
})
|
|
|
|
const cardsByRenderIndex = new Map<number, TurnChangeCardModel[]>()
|
|
turnChangeCards.forEach((card) => {
|
|
// Tool usage alone does not establish a file change. Omit empty change
|
|
// cards even when Bash coverage is unverified; conversation-only targets
|
|
// keep their separate lightweight action.
|
|
if (
|
|
card.checkpoint.code.available &&
|
|
card.checkpoint.code.filesChanged.length === 0
|
|
) return
|
|
const renderIndex =
|
|
lastResponseIndexByTurnId.get(card.target.messageId) ??
|
|
userIndexByTurnId.get(card.target.messageId)
|
|
if (renderIndex === undefined) return
|
|
const existing = cardsByRenderIndex.get(renderIndex)
|
|
if (existing) {
|
|
existing.push(card)
|
|
} else {
|
|
cardsByRenderIndex.set(renderIndex, [card])
|
|
}
|
|
})
|
|
|
|
return cardsByRenderIndex
|
|
}
|
|
|
|
/**
|
|
* Map each render item to the REAL changed files of the turn it belongs to, so an
|
|
* assistant message can anchor its output chips on files that were actually
|
|
* written this turn instead of guessing paths from the prose. Items are attributed
|
|
* to the most recent preceding non-pending user message (the turn boundary).
|
|
*/
|
|
function buildChangedFilesByRenderIndex(
|
|
renderItems: RenderItem[],
|
|
turnChangeCards: TurnChangeCardModel[],
|
|
): Map<number, string[]> {
|
|
const filesByTurnId = new Map<string, string[]>()
|
|
for (const card of turnChangeCards) {
|
|
filesByTurnId.set(card.target.messageId, card.checkpoint.code.filesChanged)
|
|
}
|
|
if (filesByTurnId.size === 0) return new Map()
|
|
|
|
const filesByRenderIndex = new Map<number, string[]>()
|
|
let activeTurnId: string | null = null
|
|
renderItems.forEach((item, index) => {
|
|
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
|
|
activeTurnId = item.message.id
|
|
return
|
|
}
|
|
if (activeTurnId) {
|
|
const files = filesByTurnId.get(activeTurnId)
|
|
if (files) filesByRenderIndex.set(index, files)
|
|
}
|
|
})
|
|
|
|
return filesByRenderIndex
|
|
}
|
|
|
|
/**
|
|
* Pick one assistant message per checkpointed turn to own generated-artifact
|
|
* fallback cards. Every assistant message still receives changedFiles for path
|
|
* reconciliation; only the last visible reply may append files absent from its
|
|
* prose, otherwise each progress update repeats the same turn-wide artifacts.
|
|
*/
|
|
function buildTurnOutputOwnerIndexes(
|
|
renderItems: RenderItem[],
|
|
turnChangeCards: TurnChangeCardModel[],
|
|
): Set<number> {
|
|
const checkpointTurnIds = new Set(
|
|
turnChangeCards.map((card) => card.target.messageId),
|
|
)
|
|
if (checkpointTurnIds.size === 0) return new Set()
|
|
|
|
const lastAssistantIndexByTurnId = new Map<string, number>()
|
|
let activeTurnId: string | null = null
|
|
renderItems.forEach((item, index) => {
|
|
if (item.kind === 'message' && item.message.type === 'user_text' && !item.message.pending) {
|
|
activeTurnId = item.message.id
|
|
return
|
|
}
|
|
if (
|
|
activeTurnId &&
|
|
checkpointTurnIds.has(activeTurnId) &&
|
|
item.kind === 'message' &&
|
|
item.message.type === 'assistant_text'
|
|
) {
|
|
lastAssistantIndexByTurnId.set(activeTurnId, index)
|
|
}
|
|
})
|
|
|
|
return new Set(lastAssistantIndexByTurnId.values())
|
|
}
|
|
|
|
/**
|
|
* Where a render item sits inside its turn, which is what decides its spacing.
|
|
*
|
|
* `none` is the user bubble: it opens an exchange, so it carries the large gap
|
|
* that separates one turn from the last. Everything else is a response and takes
|
|
* the tight within-turn gap. The names still read positionally because the
|
|
* boundaries are what the spacing is derived from — see `.chat-turn-rail*`,
|
|
* which owns the actual values and deliberately draws nothing.
|
|
*/
|
|
export type TurnRailPosition = 'none' | 'start' | 'middle' | 'end' | 'solo'
|
|
|
|
/**
|
|
* Rail position for every render item, indexed alongside `renderItems`.
|
|
*
|
|
* Deliberately breaks on EVERY `user_text`, unlike the turn-attribution walks above
|
|
* (`buildTurnCardInsertionMap`, `buildChangedFilesByRenderIndex`,
|
|
* `buildTurnOutputOwnerIndexes`, `getBranchableMessageTargets`) which skip
|
|
* `pending` ones. Those answer "which turn owns this checkpoint", and a member
|
|
* session's pending echo must not steal
|
|
* ownership. This answers "where does the line stop", and a pending message still
|
|
* renders as a visible right-aligned bubble (see the `user_text` case in
|
|
* `MessageBlock`) — a bubble mid-column is a break whatever it means for
|
|
* attribution. Do not "fix" this to match the others.
|
|
*/
|
|
export function buildTurnRailPositions(
|
|
renderItems: RenderItem[],
|
|
options: { hasTrailingStreamingItem?: boolean } = {},
|
|
): TurnRailPosition[] {
|
|
const positions: TurnRailPosition[] = new Array<TurnRailPosition>(renderItems.length).fill('none')
|
|
let runStart = -1
|
|
|
|
/** Label the open run [runStart, endExclusive). `continues` = the streaming
|
|
* block will pick the line up below, so the run must not cap itself. */
|
|
const closeRun = (endExclusive: number, continues: boolean) => {
|
|
if (runStart < 0) return
|
|
const last = endExclusive - 1
|
|
for (let index = runStart; index <= last; index += 1) {
|
|
positions[index] = index === runStart ? 'start' : 'middle'
|
|
}
|
|
if (!continues) positions[last] = runStart === last ? 'solo' : 'end'
|
|
runStart = -1
|
|
}
|
|
|
|
renderItems.forEach((item, index) => {
|
|
if (item.kind === 'message' && item.message.type === 'user_text') {
|
|
closeRun(index, false)
|
|
positions[index] = 'none'
|
|
return
|
|
}
|
|
if (runStart < 0) runStart = index
|
|
})
|
|
|
|
closeRun(renderItems.length, Boolean(options.hasTrailingStreamingItem))
|
|
|
|
return positions
|
|
}
|
|
|
|
/**
|
|
* Rail position for the live streaming reply, which renders outside the virtual
|
|
* list. It caps whatever run the transcript left open, or stands alone when the
|
|
* user has just sent and nothing else has landed yet.
|
|
*/
|
|
export function trailingStreamingRailPosition(positions: TurnRailPosition[]): TurnRailPosition {
|
|
const last = positions[positions.length - 1]
|
|
return last === 'start' || last === 'middle' ? 'end' : 'solo'
|
|
}
|
|
|
|
function isCheckpointPreviewBudgetError(error: unknown): boolean {
|
|
return error instanceof ApiError &&
|
|
error.status === 413 &&
|
|
typeof error.body === 'object' &&
|
|
error.body !== null &&
|
|
'error' in error.body &&
|
|
error.body.error === 'HISTORY_CHECKPOINT_PREVIEW_LIMIT'
|
|
}
|
|
|
|
function isSessionTurnCheckpoint(value: unknown): value is SessionTurnCheckpoint {
|
|
if (!value || typeof value !== 'object') return false
|
|
const checkpoint = value as Partial<SessionTurnCheckpoint>
|
|
return (
|
|
Boolean(checkpoint.target) &&
|
|
typeof checkpoint.target?.targetUserMessageId === 'string' &&
|
|
typeof checkpoint.target?.userMessageIndex === 'number' &&
|
|
Boolean(checkpoint.code) &&
|
|
typeof checkpoint.code?.available === 'boolean' &&
|
|
Array.isArray(checkpoint.code?.filesChanged) &&
|
|
(checkpoint.restoreAvailable === undefined ||
|
|
typeof checkpoint.restoreAvailable === 'boolean') &&
|
|
(checkpoint.unverifiedChangeSources === undefined ||
|
|
(Array.isArray(checkpoint.unverifiedChangeSources) &&
|
|
checkpoint.unverifiedChangeSources.every((source) => typeof source === 'string')))
|
|
)
|
|
}
|
|
|
|
function normalizeTurnCheckpoints(response: unknown): SessionTurnCheckpoint[] {
|
|
if (!response || typeof response !== 'object') return []
|
|
const checkpoints = (response as { checkpoints?: unknown }).checkpoints
|
|
if (!Array.isArray(checkpoints)) return []
|
|
return checkpoints.filter(isSessionTurnCheckpoint)
|
|
}
|
|
|
|
function memoryFileLabel(path: string) {
|
|
const normalized = path.replace(/\\/g, '/')
|
|
return normalized.split('/').pop() || normalized
|
|
}
|
|
|
|
function openMemorySettings(path?: string) {
|
|
const ui = useUIStore.getState()
|
|
if (path) ui.setPendingMemoryPath(path)
|
|
ui.setPendingSettingsTab('memory')
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
}
|
|
|
|
function MemoryEventCard({ message }: { message: MemoryEvent }) {
|
|
const t = useTranslation()
|
|
const visibleFiles = message.files.slice(0, 3)
|
|
const hiddenCount = Math.max(0, message.files.length - visibleFiles.length)
|
|
|
|
return (
|
|
<div className="mb-3 flex justify-center px-3">
|
|
<div className="w-full max-w-2xl rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-[var(--shadow-card)]">
|
|
<div className="flex items-start gap-3">
|
|
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
|
|
<BookMarked size={15} aria-hidden="true" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="font-medium text-[var(--color-text-primary)]">
|
|
{t('chat.memorySavedTitle', { count: message.files.length })}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => openMemorySettings(message.files[0]?.path)}
|
|
icon={<Settings size={13} aria-hidden="true" />}
|
|
>
|
|
{t('chat.memoryOpenSettings')}
|
|
</Button>
|
|
</div>
|
|
{message.message ? (
|
|
<div className="mt-1 text-[var(--color-text-tertiary)]">{message.message}</div>
|
|
) : null}
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{visibleFiles.map((file) => (
|
|
<span
|
|
key={file.path}
|
|
title={file.path}
|
|
className="max-w-full truncate rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
|
|
>
|
|
{memoryFileLabel(file.path)}
|
|
</span>
|
|
))}
|
|
{hiddenCount > 0 ? (
|
|
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
|
|
{t('chat.memoryMoreFiles', { count: hiddenCount })}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type MessageListProps = {
|
|
sessionId?: string | null
|
|
compact?: boolean
|
|
mobileLayout?: boolean
|
|
onOpenAgentRun?: (payload: OpenAgentRunPayload) => void
|
|
/**
|
|
* Lets a host that renders the list under a non-session id (an agent run's
|
|
* own tab) tell an Agent card which session and tool ref its detail endpoint
|
|
* lives behind. Defaults to the card's own ids.
|
|
*/
|
|
resolveAgentActivityTarget?: ResolveAgentActivityTarget
|
|
}
|
|
|
|
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
|
|
const SCROLL_BOTTOM_SENTINEL = 1_000_000_000
|
|
const MAX_SCROLL_SNAPSHOTS = 100
|
|
const VIRTUALIZE_MIN_RENDER_ITEMS = 120
|
|
const VIRTUALIZE_MIN_CONTENT_CHARS = 120_000
|
|
// Touch-H5 disables content-visibility paint skipping for selection
|
|
// correctness (globals.css), which makes virtualization the only paint bound
|
|
// for long transcripts there — so it kicks in at half the desktop thresholds.
|
|
const TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS = 60
|
|
const TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS = 60_000
|
|
const VIRTUAL_OVERSCAN_PX = 1200
|
|
const VIRTUAL_DEFAULT_VIEWPORT_HEIGHT = 720
|
|
/** Floor for both estimated AND measured item heights, so it has to sit under
|
|
* the shortest real item — a collapsed activity line at ~34px. Set above that
|
|
* and every such row is recorded too tall forever: the clamp is applied to the
|
|
* ResizeObserver's reading too, so no amount of measuring corrects it. */
|
|
const VIRTUAL_MIN_ITEM_HEIGHT = 24
|
|
const VIRTUAL_MAX_ITEM_HEIGHT = 24_000
|
|
// Chromium on Windows can report up to 2px ResizeObserver oscillations at
|
|
// fractional DPI. Keep the last accepted value as the baseline so cumulative
|
|
// real growth still crosses the band; only the back-and-forth noise is dropped.
|
|
const RESIZE_OBSERVER_JITTER_MAX_DELTA_PX = 2
|
|
// Native scroll anchoring and fractional DPI can leave the WebView a few CSS
|
|
// pixels shy of its computed bottom. Rewriting that correction on every live
|
|
// delta makes the two owners fight and turns the rounding into visible bounce.
|
|
const LIVE_FOLLOW_BOTTOM_GAP_TOLERANCE_PX = 4
|
|
const USER_SCROLL_INTENT_WINDOW_MS = 500
|
|
/**
|
|
* Backstop for the disclosure suppression window. The window normally ends at
|
|
* the next animation frame (see `handleDisclosureToggle`); this only bounds it
|
|
* if that frame never runs, so a dropped rAF cannot leave follow off forever.
|
|
*/
|
|
const DISCLOSURE_FOLLOW_SUPPRESS_MS = 400
|
|
/** Collapse toggles inside the transcript, matched via event delegation. */
|
|
const CHAT_DISCLOSURE_SELECTOR = '[data-chat-disclosure="true"]'
|
|
const CONVERSATION_NAVIGATION_MIN_ITEMS = 4
|
|
const CONVERSATION_NAVIGATION_FULL_MIN_WIDTH_PX = 960
|
|
const CONVERSATION_NAVIGATION_COMPACT_MIN_WIDTH_PX = 560
|
|
const STREAMING_ASSISTANT_NAVIGATION_KEY = 'streaming-assistant-message'
|
|
const EMPTY_MESSAGES: UIMessage[] = []
|
|
const EMPTY_AGENT_TASK_NOTIFICATIONS: Record<string, AgentTaskNotification> = {}
|
|
const CHAT_SCROLL_AREA_CLASS = [
|
|
'chat-scroll-area',
|
|
'[scrollbar-width:auto]',
|
|
'[scrollbar-color:color-mix(in_srgb,var(--color-outline)_72%,transparent)_transparent]',
|
|
'[&::-webkit-scrollbar]:w-2.5',
|
|
'[&::-webkit-scrollbar-track]:bg-transparent',
|
|
'[&::-webkit-scrollbar-thumb]:rounded-full',
|
|
'[&::-webkit-scrollbar-thumb]:border-[3px]',
|
|
'[&::-webkit-scrollbar-thumb]:border-transparent',
|
|
'[&::-webkit-scrollbar-thumb]:bg-[color-mix(in_srgb,var(--color-outline)_74%,transparent)]',
|
|
'[&::-webkit-scrollbar-thumb]:bg-clip-content',
|
|
'[&::-webkit-scrollbar-thumb:hover]:border-2',
|
|
'[&::-webkit-scrollbar-thumb:hover]:bg-[color-mix(in_srgb,var(--color-outline)_90%,transparent)]',
|
|
].join(' ')
|
|
const CHAT_RENDER_ITEM_CLASS = [
|
|
'chat-render-item',
|
|
].join(' ')
|
|
|
|
/**
|
|
* Carries the turn rail. Kept separate from `chat-render-item` on purpose: the
|
|
* streaming reply and the turn status line also sit on the rail but are not
|
|
* transcript items, and `.chat-render-item` is counted exactly in tests.
|
|
*/
|
|
const CHAT_TURN_RAIL_CLASS = 'chat-turn-rail'
|
|
|
|
function turnRailClass(position: TurnRailPosition): string {
|
|
return `${CHAT_TURN_RAIL_CLASS} chat-turn-rail--${position}`
|
|
}
|
|
|
|
export function isRenderItemFullyVisibleInChatScroller(renderItem: HTMLElement) {
|
|
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')
|
|
if (!scroller) return false
|
|
|
|
const itemRect = renderItem.getBoundingClientRect()
|
|
const scrollerRect = scroller.getBoundingClientRect()
|
|
return itemRect.top >= scrollerRect.top &&
|
|
itemRect.bottom <= scrollerRect.bottom &&
|
|
itemRect.left >= scrollerRect.left &&
|
|
itemRect.right <= scrollerRect.right
|
|
}
|
|
|
|
type SessionScrollSnapshot = {
|
|
scrollTop: number
|
|
wasAtBottom: boolean
|
|
}
|
|
|
|
type VirtualViewport = {
|
|
scrollTop: number
|
|
viewportHeight: number
|
|
}
|
|
|
|
type VirtualTranscriptItem = {
|
|
item: RenderItem
|
|
index: number
|
|
}
|
|
|
|
type VirtualTranscriptWindow = {
|
|
enabled: boolean
|
|
beforeHeight: number
|
|
afterHeight: number
|
|
items: VirtualTranscriptItem[]
|
|
offsets: number[]
|
|
totalHeight: number
|
|
}
|
|
|
|
type ConversationFindMatch = {
|
|
renderIndex: number
|
|
renderItemKey: string
|
|
occurrenceIndex: number
|
|
query: string
|
|
}
|
|
|
|
const MAX_CONVERSATION_FIND_MATCHES = 1_000
|
|
const CONVERSATION_FIND_CONTENT_REFRESH_MS = 80
|
|
|
|
const sessionScrollSnapshots = new Map<string, SessionScrollSnapshot>()
|
|
|
|
export function resetSessionScrollSnapshotsForTests() {
|
|
sessionScrollSnapshots.clear()
|
|
}
|
|
|
|
function isNearScrollBottom(element: HTMLElement) {
|
|
return (
|
|
element.scrollHeight - element.scrollTop - element.clientHeight <=
|
|
AUTO_SCROLL_BOTTOM_THRESHOLD_PX
|
|
)
|
|
}
|
|
|
|
function rememberSessionScroll(sessionId: string, element: HTMLElement, wasAtBottom = isNearScrollBottom(element)) {
|
|
if (sessionScrollSnapshots.size >= MAX_SCROLL_SNAPSHOTS && !sessionScrollSnapshots.has(sessionId)) {
|
|
const oldestSessionId = sessionScrollSnapshots.keys().next().value
|
|
if (oldestSessionId) {
|
|
sessionScrollSnapshots.delete(oldestSessionId)
|
|
}
|
|
}
|
|
|
|
sessionScrollSnapshots.set(sessionId, {
|
|
scrollTop: element.scrollTop,
|
|
wasAtBottom,
|
|
})
|
|
}
|
|
|
|
function getBottomScrollTop(element: HTMLElement) {
|
|
return Math.max(0, element.scrollHeight - element.clientHeight)
|
|
}
|
|
|
|
function setScrollTopWithoutLayoutRead(element: HTMLElement, scrollTop: number) {
|
|
element.scrollTop = Math.max(0, scrollTop)
|
|
}
|
|
|
|
function setScrollToBottomWithoutLayoutRead(element: HTMLElement) {
|
|
element.scrollTop = SCROLL_BOTTOM_SENTINEL
|
|
|
|
// Browsers clamp the large value to the true bottom without needing us to
|
|
// synchronously read layout metrics. JSDOM test doubles do not clamp, so keep
|
|
// the old numeric behavior there as a fallback.
|
|
if (element.scrollTop === SCROLL_BOTTOM_SENTINEL) {
|
|
element.scrollTop = getBottomScrollTop(element)
|
|
}
|
|
}
|
|
|
|
function clampNumber(value: number, min: number, max: number) {
|
|
return Math.min(max, Math.max(min, value))
|
|
}
|
|
|
|
function getRenderItemKey(item: RenderItem) {
|
|
if (item.kind === 'tool_group' || item.kind === 'team_card') return item.id
|
|
return item.message.id
|
|
}
|
|
|
|
function findConversationMatches(
|
|
renderItems: RenderItem[],
|
|
streamingText: string,
|
|
query: string,
|
|
): ConversationFindMatch[] {
|
|
const needle = query.toLocaleLowerCase()
|
|
if (!needle) return []
|
|
const matches: ConversationFindMatch[] = []
|
|
|
|
renderItems.forEach((item, renderIndex) => {
|
|
if (matches.length >= MAX_CONVERSATION_FIND_MATCHES || item.kind !== 'message') return
|
|
const message = item.message
|
|
if (message.type !== 'user_text' && message.type !== 'assistant_text') return
|
|
let occurrenceIndex = 0
|
|
const text = message.content.toLocaleLowerCase()
|
|
let offset = text.indexOf(needle)
|
|
while (offset !== -1 && matches.length < MAX_CONVERSATION_FIND_MATCHES) {
|
|
matches.push({
|
|
renderIndex,
|
|
renderItemKey: getRenderItemKey(item),
|
|
occurrenceIndex,
|
|
query,
|
|
})
|
|
occurrenceIndex += 1
|
|
offset = text.indexOf(needle, offset + needle.length)
|
|
}
|
|
})
|
|
|
|
if (matches.length < MAX_CONVERSATION_FIND_MATCHES && streamingText.trim()) {
|
|
const text = streamingText.toLocaleLowerCase()
|
|
let occurrenceIndex = 0
|
|
let offset = text.indexOf(needle)
|
|
while (offset !== -1 && matches.length < MAX_CONVERSATION_FIND_MATCHES) {
|
|
matches.push({
|
|
renderIndex: renderItems.length,
|
|
renderItemKey: STREAMING_ASSISTANT_NAVIGATION_KEY,
|
|
occurrenceIndex,
|
|
query,
|
|
})
|
|
occurrenceIndex += 1
|
|
offset = text.indexOf(needle, offset + needle.length)
|
|
}
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
function collectConversationFindRanges(root: Node, query: string) {
|
|
const ranges: Range[] = []
|
|
const needle = query.toLocaleLowerCase()
|
|
if (!needle) return ranges
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
acceptNode(node) {
|
|
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
|
|
if (node.parentElement?.closest('[data-find-bar], script, style, noscript, .material-symbols-outlined')) {
|
|
return NodeFilter.FILTER_REJECT
|
|
}
|
|
return NodeFilter.FILTER_ACCEPT
|
|
},
|
|
})
|
|
|
|
let textNode = walker.nextNode() as Text | null
|
|
while (textNode) {
|
|
const text = textNode.nodeValue?.toLocaleLowerCase() ?? ''
|
|
let offset = text.indexOf(needle)
|
|
while (offset !== -1 && ranges.length < MAX_CONVERSATION_FIND_MATCHES) {
|
|
const range = document.createRange()
|
|
range.setStart(textNode, offset)
|
|
range.setEnd(textNode, offset + needle.length)
|
|
ranges.push(range)
|
|
offset = text.indexOf(needle, offset + needle.length)
|
|
}
|
|
if (ranges.length >= MAX_CONVERSATION_FIND_MATCHES) break
|
|
textNode = walker.nextNode() as Text | null
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
function clearConversationFindHighlights() {
|
|
const highlights = (globalThis.CSS as any)?.highlights as Map<string, unknown> | undefined
|
|
highlights?.delete('cc-find-results')
|
|
highlights?.delete('cc-find-active')
|
|
}
|
|
|
|
function paintConversationFindHighlights(root: HTMLElement, match: ConversationFindMatch) {
|
|
const highlights = (globalThis.CSS as any)?.highlights as Map<string, unknown> | undefined
|
|
const HighlightCtor = (globalThis as any).Highlight
|
|
if (!highlights || !HighlightCtor) return
|
|
|
|
const resultRanges = collectConversationFindRanges(root, match.query)
|
|
const target = Array.from(root.querySelectorAll<HTMLElement>('[data-chat-render-item-key]'))
|
|
.find((node) => node.dataset.chatRenderItemKey === match.renderItemKey)
|
|
const targetRanges = target
|
|
? resultRanges.filter((range) => target.contains(range.startContainer))
|
|
: []
|
|
const activeRange = targetRanges[Math.min(match.occurrenceIndex, Math.max(0, targetRanges.length - 1))]
|
|
|
|
const results = new HighlightCtor()
|
|
for (const range of resultRanges) results.add(range)
|
|
highlights.set('cc-find-results', results)
|
|
|
|
if (activeRange) {
|
|
const active = new HighlightCtor()
|
|
active.add(activeRange)
|
|
active.priority = 1
|
|
highlights.set('cc-find-active', active)
|
|
} else {
|
|
highlights.delete('cc-find-active')
|
|
}
|
|
}
|
|
|
|
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function getShallowStringWeight(value: unknown, depth = 0): number {
|
|
if (typeof value === 'string') return value.length
|
|
if (!value || depth > 1) return 0
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 12).reduce((total, item) => total + getShallowStringWeight(item, depth + 1), 0)
|
|
}
|
|
if (!isRecordValue(value)) return 0
|
|
|
|
let total = 0
|
|
for (const item of Object.values(value).slice(0, 24)) {
|
|
total += getShallowStringWeight(item, depth + 1)
|
|
if (total >= VIRTUALIZE_MIN_CONTENT_CHARS) return total
|
|
}
|
|
return total
|
|
}
|
|
|
|
function getMessageContentWeight(message: UIMessage): number {
|
|
switch (message.type) {
|
|
case 'user_text':
|
|
case 'assistant_text':
|
|
case 'thinking':
|
|
case 'system':
|
|
return message.content.length
|
|
case 'tool_use':
|
|
return getShallowStringWeight(message.input) + (message.partialInput?.length ?? 0)
|
|
case 'tool_result':
|
|
return getShallowStringWeight(message.content)
|
|
case 'permission_request':
|
|
return getShallowStringWeight(message.input) + (message.description?.length ?? 0)
|
|
case 'error':
|
|
return message.message.length
|
|
case 'compact_summary':
|
|
return message.title.length + (message.summary?.length ?? 0)
|
|
case 'goal_event':
|
|
return (message.objective?.length ?? 0) + (message.message?.length ?? 0)
|
|
case 'memory_event':
|
|
return (message.message?.length ?? 0) + message.files.reduce((total, file) => total + file.path.length + (file.summary?.length ?? 0), 0)
|
|
case 'background_task':
|
|
return getShallowStringWeight(message.task)
|
|
case 'task_summary':
|
|
return message.tasks.reduce((total, task) => total + task.subject.length + (task.activeForm?.length ?? 0), 0)
|
|
}
|
|
}
|
|
|
|
function getRenderItemContentWeight(item: RenderItem): number {
|
|
if (item.kind === 'message') return getMessageContentWeight(item.message)
|
|
// The team card is a fixed-height summary, so it contributes no text weight
|
|
// to the virtualization heuristic.
|
|
if (item.kind === 'team_card') return 0
|
|
return item.steps.reduce(
|
|
(total, step) => total + getMessageContentWeight(step.kind === 'tool' ? step.toolCall : step.message),
|
|
0,
|
|
)
|
|
}
|
|
|
|
export function shouldVirtualizeRenderItems(
|
|
metrics: VirtualRenderItemMetric[],
|
|
touchH5 = isTouchH5Document(),
|
|
) {
|
|
const minRenderItems = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_RENDER_ITEMS : VIRTUALIZE_MIN_RENDER_ITEMS
|
|
const minContentChars = touchH5 ? TOUCH_H5_VIRTUALIZE_MIN_CONTENT_CHARS : VIRTUALIZE_MIN_CONTENT_CHARS
|
|
if (metrics.length >= minRenderItems) return true
|
|
|
|
let totalWeight = 0
|
|
for (const metric of metrics) {
|
|
totalWeight += metric.contentWeight
|
|
if (totalWeight >= minContentChars) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function countLineBreaksCapped(content: string, maxLines: number) {
|
|
let lineBreaks = 0
|
|
for (let index = 0; index < content.length; index += 1) {
|
|
if (content.charCodeAt(index) === 10) {
|
|
lineBreaks += 1
|
|
if (lineBreaks >= maxLines) return lineBreaks
|
|
}
|
|
}
|
|
return lineBreaks
|
|
}
|
|
|
|
function estimateTextHeight(content: string, baseHeight: number) {
|
|
const sample = content.length > 12_000 ? content.slice(0, 12_000) : content
|
|
const sampledLineBreaks = countLineBreaksCapped(sample, 900)
|
|
const explicitLines = content.length > sample.length
|
|
? Math.ceil((sampledLineBreaks + 1) * (content.length / sample.length))
|
|
: sampledLineBreaks + 1
|
|
const wrappedLines = Math.ceil(content.length / 76)
|
|
const estimated = baseHeight + Math.max(explicitLines, wrappedLines) * 22
|
|
return clampNumber(estimated, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
|
|
}
|
|
|
|
/* Base heights are the non-text chrome of one item, measured as a border box so
|
|
* they match what the ResizeObserver reports. Post-rail that is:
|
|
* prompt 24 rail padding-top (the turn gap) + 8 padding-bottom
|
|
* + 26 bubble padding + 36 action bar = 94
|
|
* reply 8 rail padding-bottom + 36 action bar = 44 (the card is gone)
|
|
* The action bar is 36 (mt-2 + h-7) and always reserves its space — it must not
|
|
* collapse on hover, or the transcript shifts under the reader's cursor. */
|
|
function estimateMessageHeight(message: UIMessage): number {
|
|
switch (message.type) {
|
|
case 'user_text':
|
|
return estimateTextHeight(message.content, message.attachments?.length ? 160 : 94)
|
|
case 'assistant_text':
|
|
return estimateTextHeight(message.content, 44)
|
|
case 'thinking':
|
|
return estimateTextHeight(message.content, 40)
|
|
case 'tool_use':
|
|
return clampNumber(92 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 72, 2200)
|
|
case 'tool_result':
|
|
return clampNumber(88 + Math.ceil(getMessageContentWeight(message) / 120) * 18, 64, 2200)
|
|
case 'background_task':
|
|
case 'goal_event':
|
|
case 'memory_event':
|
|
case 'permission_request':
|
|
case 'task_summary':
|
|
return 110
|
|
case 'compact_summary':
|
|
return message.summary ? clampNumber(92 + Math.ceil(message.summary.length / 90) * 20, 80, 1800) : 70
|
|
case 'error':
|
|
case 'system':
|
|
return 64
|
|
}
|
|
}
|
|
|
|
/** A collapsed activity group is one header line, however many steps it holds:
|
|
* py-1 (8) + a 12.5px line (~18) + the turn's padding-bottom (8).
|
|
*
|
|
* A group only renders its rows while it is still running, and a running group
|
|
* sits at the scroll anchor where items are mounted and measured — so estimates
|
|
* are only ever consulted for the settled, collapsed form, which is this line. */
|
|
const ACTIVITY_GROUP_COLLAPSED_HEIGHT = 34
|
|
/** Avatar row plus two text lines, plus the turn's padding-bottom. */
|
|
const TEAM_CARD_HEIGHT = 86
|
|
/** Collapsed coordination disclosure below the card adds one compact row. */
|
|
const TEAM_CARD_WITH_AUDIT_HEIGHT = 116
|
|
|
|
export function estimateRenderItemHeight(item: RenderItem): number {
|
|
if (item.kind === 'message') return estimateMessageHeight(item.message)
|
|
if (item.kind === 'team_card') {
|
|
return item.coordinationToolCalls.length > 0
|
|
? TEAM_CARD_WITH_AUDIT_HEIGHT
|
|
: TEAM_CARD_HEIGHT
|
|
}
|
|
// Agent dispatch groups keep their taller per-agent cards; everything else
|
|
// collapses to the single-line activity header.
|
|
const isAgentGroup = item.toolCalls.length > 0 && item.toolCalls.every((toolCall) => toolCall.toolName === 'Agent')
|
|
if (!isAgentGroup) return ACTIVITY_GROUP_COLLAPSED_HEIGHT
|
|
const textWeight = getRenderItemContentWeight(item)
|
|
return clampNumber(92 + item.toolCalls.length * 78 + Math.ceil(textWeight / 140) * 16, 88, 2600)
|
|
}
|
|
|
|
function getMessageMetricSignature(message: UIMessage): string {
|
|
switch (message.type) {
|
|
case 'user_text':
|
|
return `${message.type}:${message.content.length}:${message.attachments?.length ?? 0}:${message.pending ? 1 : 0}`
|
|
case 'assistant_text':
|
|
case 'thinking':
|
|
case 'system':
|
|
return `${message.type}:${message.content.length}`
|
|
case 'tool_use':
|
|
return `${message.type}:${message.toolName}:${message.toolUseId}:${message.partialInput?.length ?? 0}:${message.isPending ? 1 : 0}:${message.status ?? ''}`
|
|
case 'tool_result':
|
|
return `${message.type}:${message.toolUseId}:${message.isError ? 1 : 0}`
|
|
case 'compact_summary':
|
|
return `${message.type}:${message.phase ?? ''}:${message.title.length}:${message.summary?.length ?? 0}`
|
|
case 'goal_event':
|
|
return `${message.type}:${message.action}:${message.status ?? ''}:${message.objective?.length ?? 0}:${message.message?.length ?? 0}`
|
|
case 'memory_event':
|
|
return `${message.type}:${message.event}:${message.files.length}:${message.message?.length ?? 0}`
|
|
case 'background_task':
|
|
return `${message.type}:${message.task.taskId}:${message.task.status}:${message.task.updatedAt}`
|
|
case 'permission_request':
|
|
return `${message.type}:${message.requestId}:${message.toolUseId ?? ''}:${message.description?.length ?? 0}`
|
|
case 'error':
|
|
return `${message.type}:${message.code}:${message.message.length}`
|
|
case 'task_summary':
|
|
return `${message.type}:${message.tasks.length}:${message.tasks.map((task) => task.id).join(',')}`
|
|
}
|
|
}
|
|
|
|
function getRenderItemMetricSignature(item: RenderItem): string {
|
|
if (item.kind === 'message') return getMessageMetricSignature(item.message)
|
|
if (item.kind === 'team_card') {
|
|
return `team_card:${item.id}:${item.coordinationToolCalls.length}`
|
|
}
|
|
return item.steps
|
|
.map((step) => getMessageMetricSignature(step.kind === 'tool' ? step.toolCall : step.message))
|
|
.join('|')
|
|
}
|
|
|
|
function findVirtualStartIndex(offsets: number[], target: number) {
|
|
let low = 0
|
|
let high = offsets.length - 1
|
|
while (low < high) {
|
|
const mid = Math.floor((low + high) / 2)
|
|
if ((offsets[mid + 1] ?? offsets[mid] ?? 0) < target) {
|
|
low = mid + 1
|
|
} else {
|
|
high = mid
|
|
}
|
|
}
|
|
return Math.max(0, low)
|
|
}
|
|
|
|
function findVirtualEndIndex(offsets: number[], target: number) {
|
|
let low = 0
|
|
let high = offsets.length - 1
|
|
while (low < high) {
|
|
const mid = Math.floor((low + high) / 2)
|
|
if ((offsets[mid] ?? 0) <= target) {
|
|
low = mid + 1
|
|
} else {
|
|
high = mid
|
|
}
|
|
}
|
|
return clampNumber(low + 1, 0, offsets.length - 1)
|
|
}
|
|
|
|
export function buildVirtualItemOffsets(
|
|
itemKeys: string[],
|
|
metrics: VirtualRenderItemMetric[],
|
|
measuredHeights: Map<string, number>,
|
|
) {
|
|
const offsets = new Array<number>(itemKeys.length + 1)
|
|
offsets[0] = 0
|
|
for (let index = 0; index < itemKeys.length; index += 1) {
|
|
const measuredHeight = measuredHeights.get(itemKeys[index]!)
|
|
const height = measuredHeight && measuredHeight > 0
|
|
? measuredHeight
|
|
: metrics[index]?.estimatedHeight ?? VIRTUAL_MIN_ITEM_HEIGHT
|
|
offsets[index + 1] = offsets[index]! + height
|
|
}
|
|
return offsets
|
|
}
|
|
|
|
const CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO = 0.25
|
|
|
|
export function getActiveConversationNavigationItemId(
|
|
items: ConversationNavigationItem[],
|
|
offsets: number[],
|
|
scrollTop: number,
|
|
viewportHeight: number,
|
|
) {
|
|
if (items.length === 0) return null
|
|
if (scrollTop <= 1) return items[0]!.id
|
|
const readingAnchor = scrollTop + viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
|
let activeItem = items[0]!
|
|
|
|
for (const item of items) {
|
|
if ((offsets[item.renderIndex] ?? 0) > readingAnchor) break
|
|
activeItem = item
|
|
}
|
|
|
|
return activeItem.id
|
|
}
|
|
|
|
export function getConversationNavigationTargetScrollTop(
|
|
item: ConversationNavigationItem,
|
|
offsets: number[],
|
|
viewportHeight: number,
|
|
totalHeight: number,
|
|
) {
|
|
const targetTop = offsets[item.renderIndex] ?? 0
|
|
const readingAnchor = viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
|
return clampNumber(targetTop - readingAnchor, 0, Math.max(0, totalHeight - viewportHeight))
|
|
}
|
|
|
|
function buildVirtualTranscriptWindow(
|
|
renderItems: RenderItem[],
|
|
itemKeys: string[],
|
|
metrics: VirtualRenderItemMetric[],
|
|
measuredHeights: Map<string, number>,
|
|
viewport: VirtualViewport,
|
|
overscanPx: number,
|
|
): VirtualTranscriptWindow {
|
|
const offsets = buildVirtualItemOffsets(itemKeys, metrics, measuredHeights)
|
|
const totalHeight = offsets[renderItems.length] ?? 0
|
|
if (!shouldVirtualizeRenderItems(metrics)) {
|
|
return {
|
|
enabled: false,
|
|
beforeHeight: 0,
|
|
afterHeight: 0,
|
|
items: renderItems.map((item, index) => ({ item, index })),
|
|
offsets,
|
|
totalHeight,
|
|
}
|
|
}
|
|
|
|
const viewportHeight = viewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
|
const maxScrollTop = Math.max(0, totalHeight - viewportHeight)
|
|
const scrollTop = clampNumber(viewport.scrollTop, 0, maxScrollTop)
|
|
const windowTop = Math.max(0, scrollTop - overscanPx)
|
|
const windowBottom = Math.min(totalHeight, scrollTop + viewportHeight + overscanPx)
|
|
const startIndex = findVirtualStartIndex(offsets, windowTop)
|
|
const endIndex = Math.min(renderItems.length, findVirtualEndIndex(offsets, windowBottom))
|
|
|
|
return {
|
|
enabled: true,
|
|
beforeHeight: offsets[startIndex] ?? 0,
|
|
afterHeight: totalHeight - (offsets[endIndex] ?? totalHeight),
|
|
items: renderItems.slice(startIndex, endIndex).map((item, offset) => ({
|
|
item,
|
|
index: startIndex + offset,
|
|
})),
|
|
offsets,
|
|
totalHeight,
|
|
}
|
|
}
|
|
|
|
const VIRTUAL_SPACER_CHUNK_PX = 800
|
|
|
|
function VirtualSpacer({ height, position }: { height: number; position: 'top' | 'bottom' }) {
|
|
if (height <= 0) return null
|
|
if (height <= VIRTUAL_SPACER_CHUNK_PX) {
|
|
return (
|
|
<div
|
|
data-virtual-spacer={position}
|
|
aria-hidden="true"
|
|
style={{ height }}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Splitting the spacer into chunks lets the WebView keep painting placeholder
|
|
// boxes via content-visibility:auto + contain-intrinsic-size, instead of
|
|
// leaving a single huge area unpainted while React reconciles the window.
|
|
const chunkCount = Math.max(1, Math.ceil(height / VIRTUAL_SPACER_CHUNK_PX))
|
|
const chunkHeight = Math.floor(height / chunkCount)
|
|
const remainder = height - chunkHeight * chunkCount
|
|
const chunks: Array<{ key: string; px: number }> = []
|
|
for (let i = 0; i < chunkCount; i++) {
|
|
const px = i === chunkCount - 1 ? chunkHeight + remainder : chunkHeight
|
|
chunks.push({ key: `${position}-${i}`, px })
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-virtual-spacer={position}
|
|
aria-hidden="true"
|
|
className="overflow-hidden"
|
|
// Fractional zoom rounds each chunk independently. Keep their accumulated
|
|
// error out of scrollHeight so native scrolling cannot toggle the window.
|
|
style={{ height }}
|
|
>
|
|
{chunks.map((chunk) => (
|
|
<div
|
|
key={chunk.key}
|
|
data-virtual-spacer-chunk={position}
|
|
style={{
|
|
height: chunk.px,
|
|
contentVisibility: 'auto',
|
|
containIntrinsicSize: `0 ${chunk.px}px`,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MeasuredRenderItem = memo(function MeasuredRenderItem({
|
|
itemKey,
|
|
onHeightChange,
|
|
highlighted,
|
|
railPosition,
|
|
children,
|
|
}: {
|
|
itemKey: string
|
|
onHeightChange: (itemKey: string, height: number, initial?: boolean) => void
|
|
highlighted: boolean
|
|
railPosition: TurnRailPosition
|
|
children: ReactNode
|
|
}) {
|
|
const itemRef = useRef<HTMLDivElement>(null)
|
|
|
|
useLayoutEffect(() => {
|
|
const node = itemRef.current
|
|
if (!node) return undefined
|
|
|
|
const initialHeight = node.getBoundingClientRect().height
|
|
if (initialHeight > 0) onHeightChange(itemKey, initialHeight, true)
|
|
if (typeof ResizeObserver === 'undefined') return undefined
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries[0]
|
|
if (!entry) return
|
|
// Border box, not `contentRect`: the rail's padding is the gap between
|
|
// turns, so a content-box read would under-measure every item by that gap
|
|
// and the virtualizer's offsets would drift low over a long transcript.
|
|
// `borderBoxSize` predates every browser we ship on; the fallback is for
|
|
// environments that stub ResizeObserver with `contentRect` alone.
|
|
const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height
|
|
if (Number.isFinite(height) && height > 0) {
|
|
// Keep the layout engine's fractional CSS-pixel measurement. Rounding
|
|
// each sample upward creates a discontinuity at every integer boundary:
|
|
// at fractional Windows DPI a stable row reported as 117.99/118.01px
|
|
// then becomes 118/119px, bypasses the sub-pixel guard below, and makes
|
|
// the virtual window repaint forever (#1223).
|
|
onHeightChange(itemKey, height)
|
|
}
|
|
})
|
|
observer.observe(node)
|
|
return () => observer.disconnect()
|
|
}, [itemKey, onHeightChange])
|
|
|
|
return (
|
|
<div
|
|
ref={itemRef}
|
|
data-virtual-message-item={itemKey}
|
|
data-chat-render-item-key={itemKey}
|
|
data-turn-rail={railPosition}
|
|
className={`${CHAT_RENDER_ITEM_CLASS} ${turnRailClass(railPosition)} ${highlighted ? 'chat-render-item--navigation-target' : ''}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export function MessageList({
|
|
sessionId,
|
|
compact = false,
|
|
mobileLayout = false,
|
|
onOpenAgentRun,
|
|
resolveAgentActivityTarget,
|
|
}: MessageListProps = {}) {
|
|
const activeTabId = useTabStore((s) => s.activeTabId)
|
|
const resolvedSessionId = sessionId ?? activeTabId
|
|
const isWorkspacePanelOpen = useWorkspaceStore((state) =>
|
|
resolvedSessionId ? (state.bySession[resolvedSessionId]?.layout ?? 'hidden') !== 'hidden' : false,
|
|
)
|
|
const workspacePanelOrigin = useWorkspaceStore((state) =>
|
|
resolvedSessionId ? state.bySession[resolvedSessionId]?.origin ?? null : null,
|
|
)
|
|
const sessionState = useChatStore((s) =>
|
|
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
|
|
)
|
|
const branchSession = useSessionStore((s) => s.branchSession)
|
|
const reloadHistory = useChatStore((s) => s.reloadHistory)
|
|
const loadOlderHistory = useChatStore((s) => s.loadOlderHistory)
|
|
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
|
|
const memberSessionTeam = useTeamStore((s) => (
|
|
resolvedSessionId ? s.getTeamByMemberSessionId(resolvedSessionId) : null
|
|
))
|
|
const isMemberSession = Boolean(memberSessionTeam)
|
|
const isAgentRunTab = useTabStore((s) => s.tabs.some((tab) => (
|
|
tab.sessionId === resolvedSessionId &&
|
|
(tab.type === 'subagent' || tab.type === 'team-member')
|
|
)))
|
|
const isDirectAgentSession = isMemberSession || isAgentRunTab
|
|
const isSideChatSession = useSideChatStore((s) => Boolean(resolvedSessionId && s.entries[resolvedSessionId]))
|
|
const teamWorkbench = useTeamStore((s) =>
|
|
resolvedSessionId ? s.workbenchesBySession[resolvedSessionId] : undefined,
|
|
)
|
|
const activeTeamName = useTeamStore((s) => (
|
|
resolvedSessionId ? s.teamNameBySession[resolvedSessionId] : undefined
|
|
))
|
|
const activeTeamStartedAt = useTeamStore((s) => (
|
|
resolvedSessionId ? s.activeTeamStartedAtBySession[resolvedSessionId] : undefined
|
|
))
|
|
const teamSnapshot = useMemo(() => {
|
|
const snapshots = teamWorkbench?.snapshots
|
|
return snapshots?.length
|
|
? snapshotWithHistoricalMembers(snapshots, snapshots.length - 1)
|
|
: undefined
|
|
}, [teamWorkbench?.snapshots])
|
|
const teamTaskWindows = useMemo(
|
|
() => teamTaskWindowsForSnapshot(teamSnapshot, activeTeamStartedAt),
|
|
[activeTeamStartedAt, teamSnapshot],
|
|
)
|
|
const openTeamWorkbench = useCallback((leadSessionId: string, teamName: string) => {
|
|
useTabStore.getState().openTeamWorkbenchTab(leadSessionId, teamName)
|
|
}, [])
|
|
const teamMemberNames = useMemo(() => {
|
|
if (!teamSnapshot) return undefined
|
|
return new Set(teamSnapshot.team.members.flatMap((member) =>
|
|
member.name ? [member.name] : [],
|
|
))
|
|
}, [teamSnapshot])
|
|
const addToast = useUIStore((s) => s.addToast)
|
|
const messages = sessionState?.messages ?? EMPTY_MESSAGES
|
|
const checkpointHistoryReady = sessionState?.historyStatus === 'ready' && sessionState?.historyHydrated === true
|
|
const chatState = sessionState?.chatState ?? 'idle'
|
|
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
|
|
const historyMutationEpoch = sessionState?.historyMutationEpoch ?? 0
|
|
const streamingText = sessionState?.streamingText ?? ''
|
|
const streamingToolInput = sessionState?.streamingToolInput ?? ''
|
|
const activeThinkingId = sessionState?.activeThinkingId ?? null
|
|
const hasApiRetry = Boolean(sessionState?.apiRetry)
|
|
const hasStreamingFallback = Boolean(sessionState?.streamingFallback)
|
|
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? EMPTY_AGENT_TASK_NOTIFICATIONS
|
|
const backgroundAgentTasks = sessionState?.backgroundAgentTasks
|
|
const agentTaskStatuses = useMemo<Record<string, BackgroundAgentTask['status']>>(() => {
|
|
const statuses: Record<string, BackgroundAgentTask['status']> = {}
|
|
for (const task of Object.values(backgroundAgentTasks ?? {})) {
|
|
if (task.toolUseId) statuses[task.toolUseId] = task.status
|
|
}
|
|
return statuses
|
|
}, [backgroundAgentTasks])
|
|
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(backgroundAgentTasks)
|
|
const pendingPermissions = listPendingPermissions(sessionState)
|
|
const activeAskUserQuestionToolUseId =
|
|
pendingPermissions
|
|
.find((permission) => permission.toolName === 'AskUserQuestion')?.toolUseId ?? null
|
|
const hasPendingPermissionCard = pendingPermissions.some(
|
|
(permission) => permission.toolName !== 'AskUserQuestion',
|
|
)
|
|
const shouldFollowContentResize =
|
|
isPreparingTurn ||
|
|
streamingText.trim().length > 0 ||
|
|
chatState === 'streaming' ||
|
|
chatState === 'compacting' ||
|
|
chatState === 'tool_executing' ||
|
|
hasPendingPermissionCard ||
|
|
(chatState === 'thinking' && Boolean(activeThinkingId))
|
|
const appearanceSignature = useChatAppearanceStore((state) =>
|
|
`${state.appearance.font}:${state.appearance.fontSize}:${state.appearance.width}`,
|
|
)
|
|
const previousAppearanceSignature = useRef(appearanceSignature)
|
|
const pendingAppearanceScrollTop = useRef<number | null>(null)
|
|
const messageListRef = useRef<HTMLDivElement>(null)
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
const scrollContentRef = useRef<HTMLDivElement>(null)
|
|
const [initialVirtualItemHeights] = useState(() =>
|
|
resolvedSessionId ? getHeightsForSession(resolvedSessionId, appearanceSignature) : new Map<string, number>(),
|
|
)
|
|
const virtualItemHeightsRef = useRef(initialVirtualItemHeights)
|
|
const virtualItemMetricCacheRef = useRef<Map<string, VirtualRenderItemMetric>>(
|
|
resolvedSessionId ? getMetricsForSession(resolvedSessionId) : new Map<string, VirtualRenderItemMetric>(),
|
|
)
|
|
const liveFollowFrameRef = useRef<number | null>(null)
|
|
const navigationHighlightTimerRef = useRef<number | null>(null)
|
|
const workspaceOriginRestoreFrameRef = useRef<number | null>(null)
|
|
const conversationFindRefreshTimerRef = useRef<number | null>(null)
|
|
const conversationFindLastRefreshAtRef = useRef(0)
|
|
const workspaceOriginSessionRef = useRef(resolvedSessionId)
|
|
const lastAutoScrollAtRef = useRef(0)
|
|
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
|
|
const shouldAutoScrollRef = useRef(true)
|
|
const isProgrammaticScrollingRef = useRef(false)
|
|
const ignoreProgrammaticScrollUntilRef = useRef(0)
|
|
const ignoreProgrammaticScrollTopRef = useRef<number | null>(null)
|
|
const userScrollIntentUntilRef = useRef(0)
|
|
const disclosureLayoutUntilRef = useRef(0)
|
|
const lastSessionIdRef = useRef<string | null | undefined>(undefined)
|
|
const lastTailMessageIdBySessionRef = useRef(new Map<string, string | null>())
|
|
const lastLiveFollowInputRef = useRef({
|
|
sessionId: resolvedSessionId,
|
|
messageCount: messages.length,
|
|
streamingText,
|
|
streamingToolInput,
|
|
})
|
|
const t = useTranslation()
|
|
// Keep disclosure choices across virtualized row remounts and later turns.
|
|
const [expandedChangeCards, setExpandedChangeCards] = useState<Record<string, boolean>>({})
|
|
const [turnChangeCards, setTurnChangeCards] = useState<TurnChangeCardModel[]>([])
|
|
const [turnChangeLoadError, setTurnChangeLoadError] = useState<string | null>(null)
|
|
const [workspaceChangesFallback, setWorkspaceChangesFallback] = useState<{
|
|
sessionId: string
|
|
files: WorkspaceChangedFile[] | null
|
|
} | null>(null)
|
|
const [turnActionErrors, setTurnActionErrors] = useState<Record<string, string>>({})
|
|
const [isLoadingTurnChangeCards, setIsLoadingTurnChangeCards] = useState(false)
|
|
const [branchingMessageId, setBranchingMessageId] = useState<string | null>(null)
|
|
const [rewindingTurnId, setRewindingTurnId] = useState<string | null>(null)
|
|
const [turnUndoConfirmTargetId, setTurnUndoConfirmTargetId] = useState<string | null>(null)
|
|
const [isAwayFromLatest, setIsAwayFromLatest] = useState(false)
|
|
const [virtualViewport, setVirtualViewport] = useState<VirtualViewport>({
|
|
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
|
viewportHeight: VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
|
})
|
|
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
|
|
const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState<string | null>(null)
|
|
/** The tool call inside the highlighted item a jump is about, if any. */
|
|
const [highlightedNavigationToolUseId, setHighlightedNavigationToolUseId] = useState<string | null>(null)
|
|
const [programmaticNavigationItemId, setProgrammaticNavigationItemId] = useState<string | null>(null)
|
|
const [activeConversationFindMatch, setActiveConversationFindMatch] = useState<ConversationFindMatch | null>(null)
|
|
const conversationFindMatchesRef = useRef<ConversationFindMatch[]>([])
|
|
const [messageListWidth, setMessageListWidth] = useState<number | null>(null)
|
|
const branchActionsDisabled =
|
|
isDirectAgentSession ||
|
|
isPreparingTurn ||
|
|
chatState !== 'idle' ||
|
|
hasRunningBackgroundTasks ||
|
|
streamingText.trim().length > 0 ||
|
|
Boolean(activeThinkingId) ||
|
|
Boolean(sessionState?.activeToolUseId) ||
|
|
Boolean(sessionState?.activeToolName)
|
|
const hasCompactingDivider = messages.some((message) =>
|
|
message.type === 'compact_summary' && message.phase === 'compacting')
|
|
|
|
useEffect(() => () => {
|
|
if (liveFollowFrameRef.current !== null) {
|
|
cancelAnimationFrame(liveFollowFrameRef.current)
|
|
}
|
|
if (navigationHighlightTimerRef.current !== null) {
|
|
window.clearTimeout(navigationHighlightTimerRef.current)
|
|
}
|
|
if (workspaceOriginRestoreFrameRef.current !== null) {
|
|
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
|
}
|
|
if (conversationFindRefreshTimerRef.current !== null) {
|
|
window.clearTimeout(conversationFindRefreshTimerRef.current)
|
|
}
|
|
clearConversationFindHighlights()
|
|
}, [])
|
|
|
|
useLayoutEffect(() => {
|
|
const messageList = messageListRef.current
|
|
if (!messageList) return
|
|
|
|
const updateWidth = (width: number) => {
|
|
const roundedWidth = Math.round(width)
|
|
if (roundedWidth <= 0) return
|
|
setMessageListWidth((current) => current === roundedWidth ? current : roundedWidth)
|
|
}
|
|
|
|
updateWidth(messageList.getBoundingClientRect().width || messageList.clientWidth)
|
|
if (typeof ResizeObserver === 'undefined') return
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries.find((candidate) => candidate.target === messageList)
|
|
if (entry) updateWidth(entry.contentRect.width)
|
|
})
|
|
observer.observe(messageList)
|
|
return () => observer.disconnect()
|
|
}, [])
|
|
|
|
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
|
|
const nextScrollTop = container.scrollTop
|
|
const nextViewportHeight = container.clientHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
|
setVirtualViewport((current) => {
|
|
if (
|
|
Math.abs(current.scrollTop - nextScrollTop) < 1 &&
|
|
Math.abs(current.viewportHeight - nextViewportHeight) < 1
|
|
) {
|
|
return current
|
|
}
|
|
return {
|
|
scrollTop: nextScrollTop,
|
|
viewportHeight: nextViewportHeight,
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
shouldAutoScrollRef.current = true
|
|
isProgrammaticScrollingRef.current = true
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
lastAutoScrollAtRef.current = performance.now()
|
|
const container = scrollContainerRef.current
|
|
if (container) {
|
|
setScrollToBottomWithoutLayoutRead(container)
|
|
ignoreProgrammaticScrollTopRef.current = container.scrollTop
|
|
}
|
|
setVirtualViewport((current) => ({
|
|
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
|
viewportHeight: current.viewportHeight,
|
|
}))
|
|
if (container && resolvedSessionId) {
|
|
sessionScrollSnapshots.set(resolvedSessionId, {
|
|
scrollTop: container.scrollTop,
|
|
wasAtBottom: true,
|
|
})
|
|
}
|
|
setIsAwayFromLatest(false)
|
|
// Keep this path to one native scroll write. A second write in the next
|
|
// frame can fight Chromium's scroll anchoring at fractional Windows DPI.
|
|
requestAnimationFrame(() => {
|
|
isProgrammaticScrollingRef.current = false
|
|
})
|
|
}, [resolvedSessionId])
|
|
|
|
const requestLiveFollow = useCallback(() => {
|
|
if (!shouldAutoScrollRef.current || liveFollowFrameRef.current !== null) return
|
|
|
|
liveFollowFrameRef.current = requestAnimationFrame(() => {
|
|
liveFollowFrameRef.current = null
|
|
const container = scrollContainerRef.current
|
|
if (!container || !shouldAutoScrollRef.current) return
|
|
|
|
const bottomScrollTop = getBottomScrollTop(container)
|
|
const bottomGap = bottomScrollTop - container.scrollTop
|
|
if (Math.abs(bottomGap) > LIVE_FOLLOW_BOTTOM_GAP_TOLERANCE_PX) {
|
|
isProgrammaticScrollingRef.current = true
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
lastAutoScrollAtRef.current = performance.now()
|
|
setScrollTopWithoutLayoutRead(container, bottomScrollTop)
|
|
ignoreProgrammaticScrollTopRef.current = container.scrollTop
|
|
setVirtualViewport((current) => ({
|
|
scrollTop: container.scrollTop,
|
|
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
|
}))
|
|
requestAnimationFrame(() => {
|
|
isProgrammaticScrollingRef.current = false
|
|
})
|
|
}
|
|
|
|
if (resolvedSessionId) {
|
|
sessionScrollSnapshots.set(resolvedSessionId, {
|
|
scrollTop: container.scrollTop,
|
|
wasAtBottom: true,
|
|
})
|
|
}
|
|
setIsAwayFromLatest(false)
|
|
})
|
|
}, [resolvedSessionId])
|
|
|
|
const flushMeasuredHeightVersion = useCallback(() => {
|
|
setMeasuredItemsVersion((version) => version + 1)
|
|
}, [])
|
|
|
|
const handleVirtualItemHeightChange = useCallback((itemKey: string, height: number, initial = false) => {
|
|
const measuredHeight = clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT)
|
|
const previousHeight = virtualItemHeightsRef.current.get(itemKey)
|
|
if (
|
|
previousHeight !== undefined &&
|
|
Math.abs(previousHeight - measuredHeight) <= RESIZE_OBSERVER_JITTER_MAX_DELTA_PX
|
|
) return
|
|
|
|
virtualItemHeightsRef.current.set(itemKey, measuredHeight)
|
|
if (hasPendingPermissionCard && shouldAutoScrollRef.current) {
|
|
requestLiveFollow()
|
|
}
|
|
|
|
// Mount measurements commit before paint; observer measurements must update
|
|
// offsets and restore the reading anchor in the same frame as the new height.
|
|
if (initial) flushMeasuredHeightVersion()
|
|
else flushSync(flushMeasuredHeightVersion)
|
|
}, [flushMeasuredHeightVersion, hasPendingPermissionCard, requestLiveFollow])
|
|
|
|
const updateAutoScrollState = useCallback(() => {
|
|
// Ignore scroll events triggered by our own programmatic scrolling to
|
|
// prevent the jump-to-latest button from flickering during auto-scroll.
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
const matchesProgrammaticScrollTop =
|
|
ignoreProgrammaticScrollTopRef.current !== null &&
|
|
Math.abs(container.scrollTop - ignoreProgrammaticScrollTopRef.current) < 1
|
|
const shouldIgnoreRecentProgrammaticScroll =
|
|
matchesProgrammaticScrollTop &&
|
|
(
|
|
isProgrammaticScrollingRef.current ||
|
|
performance.now() < ignoreProgrammaticScrollUntilRef.current
|
|
)
|
|
if (shouldIgnoreRecentProgrammaticScroll) {
|
|
syncVirtualViewportFromContainer(container)
|
|
return
|
|
}
|
|
if (performance.now() < userScrollIntentUntilRef.current) {
|
|
setProgrammaticNavigationItemId(null)
|
|
}
|
|
syncVirtualViewportFromContainer(container)
|
|
const isAtBottom = isNearScrollBottom(container)
|
|
const isPermissionLayoutShift =
|
|
hasPendingPermissionCard &&
|
|
shouldAutoScrollRef.current &&
|
|
!isAtBottom &&
|
|
performance.now() >= userScrollIntentUntilRef.current
|
|
if (isPermissionLayoutShift) return
|
|
|
|
shouldAutoScrollRef.current = isAtBottom
|
|
setIsAwayFromLatest(!isAtBottom)
|
|
|
|
if (resolvedSessionId) {
|
|
rememberSessionScroll(resolvedSessionId, container, isAtBottom)
|
|
}
|
|
}, [hasPendingPermissionCard, resolvedSessionId, syncVirtualViewportFromContainer])
|
|
|
|
/**
|
|
* Expanding a collapsed block is the reader rearranging their own view, not
|
|
* the model producing output — so the live-follow must not treat the height
|
|
* jump as new content and yank the transcript to the bottom (#1177). Browser
|
|
* scroll anchoring already keeps the clicked row where it is; this only has to
|
|
* stop `requestLiveFollow` from overriding it for that one frame.
|
|
*
|
|
* Deliberately not `userScrollIntentUntilRef`: that one is set by any
|
|
* pointerdown on the scroller, so reusing it would suppress content-resize
|
|
* follow on every stray click and turn "no jump" into "randomly stops
|
|
* following".
|
|
*/
|
|
const handleDisclosureToggle = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
|
const target = event.target
|
|
if (!(target instanceof Element) || !target.closest(CHAT_DISCLOSURE_SELECTOR)) return
|
|
|
|
disclosureLayoutUntilRef.current = performance.now() + DISCLOSURE_FOLLOW_SUPPRESS_MS
|
|
|
|
// Suppression only covers the resize frame. Once the block is open the
|
|
// container is no longer at the bottom, and the next streamed token would
|
|
// pull it back down and throw the reader out again — so re-decide whether
|
|
// we are still following, exactly as a wheel gesture would.
|
|
requestAnimationFrame(() => {
|
|
// The expansion's resize has landed by now, so hand follow back before the
|
|
// next token arrives — suppression must never outlive the reader's click.
|
|
disclosureLayoutUntilRef.current = 0
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
const atBottom = isNearScrollBottom(container)
|
|
shouldAutoScrollRef.current = atBottom
|
|
setIsAwayFromLatest(!atBottom)
|
|
syncVirtualViewportFromContainer(container)
|
|
})
|
|
}, [syncVirtualViewportFromContainer])
|
|
|
|
const markUserScrollIntent = useCallback(() => {
|
|
userScrollIntentUntilRef.current = performance.now() + USER_SCROLL_INTENT_WINDOW_MS
|
|
}, [])
|
|
|
|
const handleWheelScrollIntent = useCallback((event: { deltaY: number }) => {
|
|
markUserScrollIntent()
|
|
if (event.deltaY < 0) {
|
|
shouldAutoScrollRef.current = false
|
|
setIsAwayFromLatest(true)
|
|
}
|
|
}, [markUserScrollIntent])
|
|
|
|
const handleKeyDownScrollIntent = useCallback((event: { key: string; shiftKey: boolean }) => {
|
|
const isUpwardScrollKey =
|
|
event.key === 'ArrowUp' ||
|
|
event.key === 'PageUp' ||
|
|
event.key === 'Home' ||
|
|
(event.key === ' ' && event.shiftKey)
|
|
const isScrollKey = isUpwardScrollKey ||
|
|
event.key === 'ArrowDown' ||
|
|
event.key === 'PageDown' ||
|
|
event.key === 'End' ||
|
|
event.key === ' '
|
|
if (!isScrollKey) return
|
|
|
|
markUserScrollIntent()
|
|
if (isUpwardScrollKey) {
|
|
shouldAutoScrollRef.current = false
|
|
setIsAwayFromLatest(true)
|
|
}
|
|
}, [markUserScrollIntent])
|
|
|
|
useLayoutEffect(() => {
|
|
if (lastSessionIdRef.current !== resolvedSessionId) {
|
|
const snapshot = resolvedSessionId ? sessionScrollSnapshots.get(resolvedSessionId) : undefined
|
|
shouldAutoScrollRef.current = snapshot?.wasAtBottom ?? true
|
|
lastSessionIdRef.current = resolvedSessionId
|
|
setProgrammaticNavigationItemId(null)
|
|
virtualItemHeightsRef.current = resolvedSessionId
|
|
? getHeightsForSession(resolvedSessionId, appearanceSignature)
|
|
: new Map<string, number>()
|
|
virtualItemMetricCacheRef.current = resolvedSessionId
|
|
? getMetricsForSession(resolvedSessionId)
|
|
: new Map<string, VirtualRenderItemMetric>()
|
|
lastContentResizeFollowHeightRef.current = null
|
|
if (liveFollowFrameRef.current !== null) {
|
|
cancelAnimationFrame(liveFollowFrameRef.current)
|
|
liveFollowFrameRef.current = null
|
|
}
|
|
setMeasuredItemsVersion((version) => version + 1)
|
|
|
|
const container = scrollContainerRef.current
|
|
if (container && snapshot && !snapshot.wasAtBottom) {
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
ignoreProgrammaticScrollTopRef.current = snapshot.scrollTop
|
|
setScrollTopWithoutLayoutRead(container, snapshot.scrollTop)
|
|
setVirtualViewport((current) => ({
|
|
scrollTop: snapshot.scrollTop,
|
|
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
|
}))
|
|
setIsAwayFromLatest(true)
|
|
} else if (container) {
|
|
// Switch to a session we were at the bottom of (or first visit): write
|
|
// the bottom sentinel without going through scrollToBottom's read path,
|
|
// so we never force a layout flush during the switch's commit.
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
ignoreProgrammaticScrollTopRef.current = null
|
|
lastAutoScrollAtRef.current = performance.now()
|
|
shouldAutoScrollRef.current = true
|
|
setScrollToBottomWithoutLayoutRead(container)
|
|
setVirtualViewport((current) => ({
|
|
scrollTop: SCROLL_BOTTOM_SENTINEL,
|
|
viewportHeight: container.clientHeight || current.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT,
|
|
}))
|
|
setIsAwayFromLatest(false)
|
|
if (resolvedSessionId) {
|
|
sessionScrollSnapshots.set(resolvedSessionId, {
|
|
scrollTop: container.scrollTop,
|
|
wasAtBottom: true,
|
|
})
|
|
}
|
|
} else {
|
|
// No container yet (initial mount before ref settles): fall back to the
|
|
// existing scrollToBottom path which is safe pre-mount.
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
}, [appearanceSignature, resolvedSessionId, scrollToBottom])
|
|
|
|
const tailMessage = messages[messages.length - 1] ?? null
|
|
const tailMessageId = tailMessage?.id ?? null
|
|
const tailMessageType = tailMessage?.type ?? null
|
|
|
|
useEffect(() => {
|
|
if (!resolvedSessionId) return
|
|
|
|
const previousTailMessageId = lastTailMessageIdBySessionRef.current.get(resolvedSessionId)
|
|
lastTailMessageIdBySessionRef.current.set(resolvedSessionId, tailMessageId)
|
|
if (previousTailMessageId === undefined || previousTailMessageId === tailMessageId) return
|
|
|
|
if (tailMessageType === 'user_text') {
|
|
scrollToBottom()
|
|
}
|
|
}, [resolvedSessionId, scrollToBottom, tailMessageId, tailMessageType])
|
|
|
|
useEffect(() => {
|
|
const previousInput = lastLiveFollowInputRef.current
|
|
lastLiveFollowInputRef.current = {
|
|
sessionId: resolvedSessionId,
|
|
messageCount: messages.length,
|
|
streamingText,
|
|
streamingToolInput,
|
|
}
|
|
// Session restoration already owns the initial/switch scroll. Only live
|
|
// transitions within the same session enter the coalesced follow path.
|
|
if (
|
|
previousInput.sessionId !== resolvedSessionId ||
|
|
(
|
|
previousInput.messageCount === messages.length &&
|
|
previousInput.streamingText === streamingText &&
|
|
previousInput.streamingToolInput === streamingToolInput
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
if (!shouldAutoScrollRef.current) {
|
|
setIsAwayFromLatest(true)
|
|
return
|
|
}
|
|
|
|
requestLiveFollow()
|
|
}, [messages.length, requestLiveFollow, resolvedSessionId, streamingText, streamingToolInput])
|
|
|
|
const handleJumpToLatest = useCallback(() => {
|
|
setProgrammaticNavigationItemId(null)
|
|
scrollToBottom()
|
|
}, [scrollToBottom])
|
|
|
|
useEffect(() => {
|
|
const content = scrollContentRef.current
|
|
if (!content || typeof ResizeObserver === 'undefined') return
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
const nextHeight = entries[0]?.contentRect.height
|
|
if (typeof nextHeight === 'number' && Number.isFinite(nextHeight)) {
|
|
const previousFollowHeight = lastContentResizeFollowHeightRef.current
|
|
if (
|
|
previousFollowHeight !== null &&
|
|
Math.abs(nextHeight - previousFollowHeight) <= RESIZE_OBSERVER_JITTER_MAX_DELTA_PX
|
|
) {
|
|
return
|
|
}
|
|
lastContentResizeFollowHeightRef.current = nextHeight
|
|
}
|
|
if (!shouldFollowContentResize) return
|
|
if (!shouldAutoScrollRef.current) return
|
|
// The reader just opened something: the growth is theirs, not the model's.
|
|
if (performance.now() < disclosureLayoutUntilRef.current) return
|
|
requestLiveFollow()
|
|
})
|
|
observer.observe(content)
|
|
|
|
return () => observer.disconnect()
|
|
}, [requestLiveFollow, shouldFollowContentResize])
|
|
|
|
// Touch-H5 only: the visual-viewport fit (touchH5.ts) shrinks the scroll
|
|
// container when the soft keyboard opens. If the user was reading the tail,
|
|
// keep the latest message pinned above the keyboard instead of letting the
|
|
// shorter container cut it off.
|
|
useEffect(() => {
|
|
if (!isTouchH5Document()) return
|
|
const container = scrollContainerRef.current
|
|
if (!container || typeof ResizeObserver === 'undefined') return
|
|
|
|
const observer = new ResizeObserver(() => {
|
|
if (!shouldAutoScrollRef.current) return
|
|
requestLiveFollow()
|
|
})
|
|
observer.observe(container)
|
|
|
|
return () => observer.disconnect()
|
|
}, [requestLiveFollow])
|
|
|
|
const {
|
|
toolResultMap,
|
|
childToolCallsByParent,
|
|
renderItems,
|
|
supersededAskUserQuestionIds,
|
|
} = useMemo(
|
|
() => buildRenderModel(messages, activeAskUserQuestionToolUseId, {
|
|
hideTeamCoordinationTools: !isDirectAgentSession,
|
|
teamMemberNames,
|
|
teamTaskWindows,
|
|
teamName: activeTeamName ?? teamSnapshot?.team.name,
|
|
teamStartedAt: activeTeamStartedAt,
|
|
}),
|
|
[
|
|
activeAskUserQuestionToolUseId,
|
|
activeTeamName,
|
|
activeTeamStartedAt,
|
|
isDirectAgentSession,
|
|
messages,
|
|
teamMemberNames,
|
|
teamSnapshot?.team.name,
|
|
teamTaskWindows,
|
|
],
|
|
)
|
|
// Defer the per-message branchable / completed-turn computations so the first
|
|
// commit on tab switch can render the virtualization window without doing two
|
|
// additional O(N) walks synchronously. They re-run in a low-priority render
|
|
// once the initial frame is painted.
|
|
const deferredMessages = useDeferredValue(messages)
|
|
const branchableMessageTargets = useMemo(
|
|
() => branchActionsDisabled
|
|
? new Map<string, BranchableMessageTarget>()
|
|
: getBranchableMessageTargets(deferredMessages),
|
|
[branchActionsDisabled, deferredMessages],
|
|
)
|
|
const completedTurnTargets = useMemo(
|
|
() => getCompletedTurnTargets(deferredMessages),
|
|
[deferredMessages],
|
|
)
|
|
const turnCompletionByMessageId = useMemo(
|
|
() => buildTurnCompletionByMessageId(deferredMessages, { turnActive: chatState !== 'idle' }),
|
|
[deferredMessages, chatState],
|
|
)
|
|
const latestCompletedTurnId =
|
|
completedTurnTargets.length > 0
|
|
? completedTurnTargets[completedTurnTargets.length - 1]?.messageId ?? null
|
|
: null
|
|
const visibleTurnChangeCards = hasRunningBackgroundTasks ? EMPTY_TURN_CHANGE_CARDS : turnChangeCards
|
|
const turnCardsByRenderIndex = useMemo(
|
|
() => buildTurnCardInsertionMap(renderItems, visibleTurnChangeCards),
|
|
[renderItems, visibleTurnChangeCards],
|
|
)
|
|
const changedFilesByRenderIndex = useMemo(
|
|
() => buildChangedFilesByRenderIndex(renderItems, turnChangeCards),
|
|
[renderItems, turnChangeCards],
|
|
)
|
|
const turnOutputOwnerIndexes = useMemo(
|
|
() => buildTurnOutputOwnerIndexes(renderItems, turnChangeCards),
|
|
[renderItems, turnChangeCards],
|
|
)
|
|
const hasTrailingStreamingItem = streamingText.trim().length > 0
|
|
const turnRailPositions = useMemo(
|
|
() => buildTurnRailPositions(renderItems, { hasTrailingStreamingItem }),
|
|
[renderItems, hasTrailingStreamingItem],
|
|
)
|
|
const streamingRailPosition = trailingStreamingRailPosition(turnRailPositions)
|
|
// The rail is the progress indicator: whichever segment the turn is currently
|
|
// working in carries the running state, so it sits next to the work it
|
|
// describes instead of in a separate strip somewhere else on screen.
|
|
const showsTurnStatusLine = hasApiRetry
|
|
|| hasStreamingFallback
|
|
|| isPreparingTurn
|
|
|| chatState === 'tool_executing'
|
|
|| (chatState === 'thinking' && !activeThinkingId)
|
|
const renderItemKeys = useMemo(
|
|
() => renderItems.map(getRenderItemKey),
|
|
[renderItems],
|
|
)
|
|
const committedGroupKeys = useRef<{ sessionId: string | null | undefined; byTool: Map<string, string> }>({ sessionId: resolvedSessionId, byTool: new Map() })
|
|
const groupMountKeys = useMemo(() => {
|
|
const previous = committedGroupKeys.current.sessionId === resolvedSessionId
|
|
? committedGroupKeys.current.byTool : new Map<string, string>()
|
|
const keys = new Map<string, string>()
|
|
const byTool = new Map<string, string>()
|
|
const used = new Set<string>()
|
|
for (const item of renderItems) {
|
|
if (item.kind !== 'tool_group') continue
|
|
// Prepending a page can rename a group. Preserve its mounted disclosure
|
|
// and inner rows through surviving tool identities in either direction.
|
|
const retained = item.toolCalls.map((tool) => previous.get(tool.id)).find((key) => key && !used.has(key))
|
|
const key = retained ?? `activity:${item.id}`
|
|
const uniqueKey = used.has(key) ? `${key}:${keys.size}` : key
|
|
used.add(uniqueKey)
|
|
keys.set(item.id, uniqueKey)
|
|
for (const tool of item.toolCalls) byTool.set(tool.id, uniqueKey)
|
|
}
|
|
return { keys, byTool }
|
|
}, [renderItems, resolvedSessionId])
|
|
useLayoutEffect(() => {
|
|
committedGroupKeys.current = { sessionId: resolvedSessionId, byTool: groupMountKeys.byTool }
|
|
}, [groupMountKeys, resolvedSessionId])
|
|
const renderItemMetrics = useMemo(
|
|
() => renderItems.map((item, index) => {
|
|
const key = renderItemKeys[index]!
|
|
const signature = getRenderItemMetricSignature(item)
|
|
const cached = virtualItemMetricCacheRef.current.get(key)
|
|
if (cached?.signature === signature) return cached
|
|
|
|
const metric = {
|
|
signature,
|
|
contentWeight: getRenderItemContentWeight(item),
|
|
estimatedHeight: estimateRenderItemHeight(item),
|
|
}
|
|
virtualItemMetricCacheRef.current.set(key, metric)
|
|
return metric
|
|
}),
|
|
[renderItemKeys, renderItems],
|
|
)
|
|
const conversationNavigationItems = useMemo(() => {
|
|
const sources = renderItems.flatMap((item, renderIndex) => item.kind === 'message'
|
|
? [{
|
|
message: item.message,
|
|
renderIndex,
|
|
renderItemKey: getRenderItemKey(item),
|
|
}]
|
|
: [])
|
|
|
|
return buildConversationNavigationItems(sources)
|
|
}, [renderItems])
|
|
const virtualTranscriptWindow = useMemo(
|
|
() => buildVirtualTranscriptWindow(
|
|
renderItems,
|
|
renderItemKeys,
|
|
renderItemMetrics,
|
|
virtualItemHeightsRef.current,
|
|
virtualViewport,
|
|
VIRTUAL_OVERSCAN_PX,
|
|
),
|
|
[measuredItemsVersion, renderItemKeys, renderItemMetrics, renderItems, virtualViewport],
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
if (previousAppearanceSignature.current === appearanceSignature) return
|
|
previousAppearanceSignature.current = appearanceSignature
|
|
const container = scrollContainerRef.current
|
|
if (resolvedSessionId) getHeightsForSession(resolvedSessionId, appearanceSignature)
|
|
if (!container || !virtualTranscriptWindow.enabled) return
|
|
|
|
// Off-screen rows retain no valid measurements after a font/measure change.
|
|
// Re-measure mounted rows before paint and preserve the reader's row plus
|
|
// its intra-row offset while the spacer above it returns to estimates.
|
|
const oldOffsets = virtualTranscriptWindow.offsets
|
|
const oldScrollTop = virtualViewport.scrollTop
|
|
let anchorIndex = 0
|
|
while (anchorIndex + 1 < renderItemKeys.length && oldOffsets[anchorIndex + 1]! <= oldScrollTop) anchorIndex += 1
|
|
virtualItemHeightsRef.current.clear()
|
|
for (const node of container.querySelectorAll<HTMLElement>('[data-virtual-message-item]')) {
|
|
const height = node.getBoundingClientRect().height
|
|
if (height > 0) virtualItemHeightsRef.current.set(node.dataset.virtualMessageItem!, clampNumber(height, VIRTUAL_MIN_ITEM_HEIGHT, VIRTUAL_MAX_ITEM_HEIGHT))
|
|
}
|
|
const offsets = buildVirtualItemOffsets(renderItemKeys, renderItemMetrics, virtualItemHeightsRef.current)
|
|
const intraRowOffset = Math.max(0, oldScrollTop - (oldOffsets[anchorIndex] ?? 0))
|
|
const rowHeight = (offsets[anchorIndex + 1] ?? 0) - (offsets[anchorIndex] ?? 0)
|
|
const nextScrollTop = shouldAutoScrollRef.current
|
|
? SCROLL_BOTTOM_SENTINEL
|
|
: (offsets[anchorIndex] ?? 0) + Math.min(intraRowOffset, Math.max(0, rowHeight - 1))
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
ignoreProgrammaticScrollTopRef.current = nextScrollTop
|
|
pendingAppearanceScrollTop.current = nextScrollTop
|
|
setVirtualViewport((current) => ({ ...current, scrollTop: nextScrollTop }))
|
|
setMeasuredItemsVersion((version) => version + 1)
|
|
}, [appearanceSignature, renderItemKeys, renderItemMetrics, resolvedSessionId, virtualTranscriptWindow, virtualViewport.scrollTop])
|
|
|
|
useLayoutEffect(() => {
|
|
const target = pendingAppearanceScrollTop.current
|
|
if (target === null || !scrollContainerRef.current) return
|
|
pendingAppearanceScrollTop.current = null
|
|
setScrollTopWithoutLayoutRead(scrollContainerRef.current, target)
|
|
ignoreProgrammaticScrollTopRef.current = scrollContainerRef.current.scrollTop
|
|
}, [measuredItemsVersion])
|
|
|
|
const activeConversationNavigationItemId = useMemo(
|
|
() => isAwayFromLatest
|
|
? getActiveConversationNavigationItemId(
|
|
conversationNavigationItems,
|
|
virtualTranscriptWindow.offsets,
|
|
virtualViewport.scrollTop,
|
|
virtualViewport.viewportHeight,
|
|
)
|
|
: null,
|
|
[conversationNavigationItems, isAwayFromLatest, virtualTranscriptWindow.offsets, virtualViewport],
|
|
)
|
|
const visibleConversationNavigationItemId =
|
|
programmaticNavigationItemId && conversationNavigationItems.some((item) => item.id === programmaticNavigationItemId)
|
|
? programmaticNavigationItemId
|
|
: activeConversationNavigationItemId
|
|
const conversationNavigationMode: ConversationNavigationMode =
|
|
messageListWidth === null || messageListWidth >= CONVERSATION_NAVIGATION_FULL_MIN_WIDTH_PX
|
|
? 'full'
|
|
: messageListWidth >= CONVERSATION_NAVIGATION_COMPACT_MIN_WIDTH_PX
|
|
? 'compact'
|
|
: 'edge'
|
|
const showConversationNavigator =
|
|
!mobileLayout &&
|
|
!isTouchH5Document() &&
|
|
conversationNavigationItems.length >= CONVERSATION_NAVIGATION_MIN_ITEMS
|
|
const chatScrollPaddingClass = compact
|
|
? showConversationNavigator && conversationNavigationMode === 'full'
|
|
? 'pb-5 px-20 py-3'
|
|
: showConversationNavigator && conversationNavigationMode === 'compact'
|
|
? 'pb-5 px-12 py-3'
|
|
: showConversationNavigator && conversationNavigationMode === 'edge'
|
|
? 'pb-5 px-7 py-3'
|
|
: 'px-3 py-3 pb-5'
|
|
: showConversationNavigator && conversationNavigationMode === 'full'
|
|
? 'px-20 py-4'
|
|
: showConversationNavigator && conversationNavigationMode === 'compact'
|
|
? 'px-12 py-4'
|
|
: showConversationNavigator && conversationNavigationMode === 'edge'
|
|
? 'px-7 py-4'
|
|
: 'px-4 py-4'
|
|
const confirmTurnCard = useMemo(
|
|
() => visibleTurnChangeCards.find((card) => card.target.messageId === turnUndoConfirmTargetId) ?? null,
|
|
[turnUndoConfirmTargetId, visibleTurnChangeCards],
|
|
)
|
|
// Undo is not reversible, so the dialog — not just the card — has to say which
|
|
// changes it will leave behind when the checkpoint could not cover them all.
|
|
const confirmUnverifiedSources = confirmTurnCard?.checkpoint.unverifiedChangeSources ?? []
|
|
const confirmHasCodeCheckpoint = Boolean(confirmTurnCard?.checkpoint.code.available)
|
|
const confirmCanRestoreCode = confirmHasCodeCheckpoint &&
|
|
confirmTurnCard?.checkpoint.restoreAvailable !== false
|
|
const confirmBodyText = confirmTurnCard?.isLatest
|
|
? t('chat.turnChangesLatestConfirmBody')
|
|
: t('chat.turnChangesHistoricalConfirmBody')
|
|
const confirmCaution = confirmHasCodeCheckpoint && !confirmCanRestoreCode
|
|
? t('chat.turnChangesConversationOnlyConfirmBody')
|
|
: confirmUnverifiedSources.length > 0
|
|
? t('chat.turnChangesPartialCoverageConfirmBody', {
|
|
sources: confirmUnverifiedSources.join(', '),
|
|
})
|
|
: null
|
|
const confirmBody = !confirmHasCodeCheckpoint
|
|
? t('chat.conversationRewindConfirmBody')
|
|
: confirmCaution === null
|
|
? confirmBodyText
|
|
: (
|
|
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-secondary)]">
|
|
{confirmCanRestoreCode ? <p>{confirmBodyText}</p> : null}
|
|
<p className="text-[var(--color-warning)]">{confirmCaution}</p>
|
|
</div>
|
|
)
|
|
|
|
useEffect(() => {
|
|
const liveKeys = new Set(renderItemKeys)
|
|
let removed = false
|
|
for (const key of virtualItemHeightsRef.current.keys()) {
|
|
if (!liveKeys.has(key)) {
|
|
virtualItemHeightsRef.current.delete(key)
|
|
removed = true
|
|
}
|
|
}
|
|
for (const key of virtualItemMetricCacheRef.current.keys()) {
|
|
if (!liveKeys.has(key)) {
|
|
virtualItemMetricCacheRef.current.delete(key)
|
|
}
|
|
}
|
|
if (removed) setMeasuredItemsVersion((version) => version + 1)
|
|
}, [renderItemKeys])
|
|
|
|
useEffect(() => {
|
|
if (!resolvedSessionId || !checkpointHistoryReady || completedTurnTargets.length === 0 || isDirectAgentSession) {
|
|
setTurnChangeCards([])
|
|
setTurnChangeLoadError(null)
|
|
setWorkspaceChangesFallback(null)
|
|
setIsLoadingTurnChangeCards(false)
|
|
return
|
|
}
|
|
|
|
if (hasRunningBackgroundTasks) {
|
|
setTurnChangeLoadError(null)
|
|
setWorkspaceChangesFallback(null)
|
|
setIsLoadingTurnChangeCards(false)
|
|
return
|
|
}
|
|
|
|
if (chatState !== 'idle') {
|
|
setTurnChangeLoadError(null)
|
|
setWorkspaceChangesFallback(null)
|
|
setIsLoadingTurnChangeCards(false)
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
const controller = new AbortController()
|
|
setIsLoadingTurnChangeCards(true)
|
|
setTurnChangeLoadError(null)
|
|
setWorkspaceChangesFallback(null)
|
|
|
|
// Keep the bounded workspace evidence even if full turn previews exceed
|
|
// their budget. These files describe the current workspace, not a turn.
|
|
Promise.allSettled([
|
|
sessionsApi.getTurnCheckpoints(resolvedSessionId, { signal: controller.signal }),
|
|
sessionsApi.getWorkspaceStatus(resolvedSessionId, controller.signal),
|
|
])
|
|
.then(([checkpointResult, workspaceResult]) => {
|
|
if (cancelled) return
|
|
const workspaceStatus = workspaceResult.status === 'fulfilled' ? workspaceResult.value : null
|
|
if (checkpointResult.status === 'rejected') {
|
|
setTurnChangeCards([])
|
|
if (isCheckpointPreviewBudgetError(checkpointResult.reason)) {
|
|
const files = workspaceStatus?.state === 'ok' &&
|
|
Array.isArray(workspaceStatus.changedFiles) &&
|
|
workspaceStatus.changedFiles.every((file) => typeof file?.path === 'string' && file.path.trim().length > 0)
|
|
? workspaceStatus.changedFiles
|
|
: null
|
|
setWorkspaceChangesFallback({ sessionId: resolvedSessionId, files })
|
|
} else {
|
|
setTurnChangeLoadError(getApiErrorMessage(checkpointResult.reason))
|
|
}
|
|
return
|
|
}
|
|
const targetByMessageId = new Map(
|
|
completedTurnTargets.map((target) => [target.messageId, target] as const),
|
|
)
|
|
const targetByUserMessageIndex = new Map(
|
|
completedTurnTargets.map((target) => [target.userMessageIndex, target] as const),
|
|
)
|
|
|
|
const nextCards = normalizeTurnCheckpoints(checkpointResult.value).flatMap((checkpoint) => {
|
|
const target =
|
|
targetByMessageId.get(checkpoint.target.targetUserMessageId) ??
|
|
(sessionState?.historyWindowed ? undefined : targetByUserMessageIndex.get(checkpoint.target.userMessageIndex))
|
|
if (!target) {
|
|
return []
|
|
}
|
|
return [{
|
|
target,
|
|
checkpoint,
|
|
workDir: checkpoint.workDir ?? workspaceStatus?.workDir ?? null,
|
|
isLatest: target.messageId === latestCompletedTurnId,
|
|
}]
|
|
})
|
|
setTurnChangeCards(nextCards)
|
|
})
|
|
.catch((error) => {
|
|
if (cancelled) return
|
|
setTurnChangeCards([])
|
|
setTurnChangeLoadError(getApiErrorMessage(error))
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) {
|
|
setIsLoadingTurnChangeCards(false)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
controller.abort()
|
|
}
|
|
}, [chatState, completedTurnTargets, hasRunningBackgroundTasks, historyMutationEpoch, isDirectAgentSession, latestCompletedTurnId, resolvedSessionId, checkpointHistoryReady])
|
|
|
|
const handleUndoCurrentTurn = useCallback(async (mode: SessionRewindMode = 'both') => {
|
|
if (!resolvedSessionId || !confirmTurnCard || rewindingTurnId || hasRunningBackgroundTasks) return
|
|
|
|
const target = confirmTurnCard.target
|
|
setRewindingTurnId(target.messageId)
|
|
setTurnActionErrors((current) => {
|
|
if (!(target.messageId in current)) return current
|
|
const next = { ...current }
|
|
delete next[target.messageId]
|
|
return next
|
|
})
|
|
|
|
try {
|
|
const result = await rewindToTurnCheckpoint(resolvedSessionId, {
|
|
checkpointTarget: confirmTurnCard.checkpoint.target,
|
|
expectedContent: target.expectedContent,
|
|
}, mode)
|
|
|
|
await reloadHistory(resolvedSessionId)
|
|
queueComposerPrefill(resolvedSessionId, {
|
|
text: target.content,
|
|
attachments: target.attachments,
|
|
})
|
|
addToast(describeRewindResult(result, mode, t))
|
|
|
|
setTurnUndoConfirmTargetId(null)
|
|
} catch (error) {
|
|
setTurnActionErrors((current) => ({
|
|
...current,
|
|
[target.messageId]: getApiErrorMessage(error),
|
|
}))
|
|
setTurnUndoConfirmTargetId(null)
|
|
} finally {
|
|
setRewindingTurnId(null)
|
|
}
|
|
}, [
|
|
addToast,
|
|
confirmTurnCard,
|
|
hasRunningBackgroundTasks,
|
|
queueComposerPrefill,
|
|
reloadHistory,
|
|
resolvedSessionId,
|
|
rewindingTurnId,
|
|
t,
|
|
])
|
|
|
|
// Rolling the conversation back never depends on the checkpoint, so it stays
|
|
// on offer even when restoring the files is impossible — that is the only
|
|
// action left in that case, and losing it entirely was the whole complaint.
|
|
const confirmActions: ActionDialogAction[] = [
|
|
{
|
|
label: t('common.cancel'),
|
|
onClick: () => setTurnUndoConfirmTargetId(null),
|
|
variant: 'secondary',
|
|
},
|
|
{
|
|
label: t('chat.turnChangesUndoConversationOnly'),
|
|
onClick: () => { void handleUndoCurrentTurn('conversation') },
|
|
variant: confirmCanRestoreCode ? 'secondary' : 'danger',
|
|
loading: Boolean(rewindingTurnId),
|
|
},
|
|
...(confirmCanRestoreCode
|
|
? [{
|
|
label: confirmTurnCard?.isLatest
|
|
? t('chat.turnChangesLatestConfirmUndo')
|
|
: t('chat.turnChangesHistoricalConfirmUndo'),
|
|
onClick: () => { void handleUndoCurrentTurn('both') },
|
|
variant: 'danger' as const,
|
|
loading: Boolean(rewindingTurnId),
|
|
}]
|
|
: []),
|
|
]
|
|
|
|
const handleBranchMessage = useCallback(async (target: BranchableMessageTarget) => {
|
|
if (!resolvedSessionId || branchingMessageId) return
|
|
|
|
setBranchingMessageId(target.uiMessageId)
|
|
try {
|
|
const result = await branchSession(resolvedSessionId, target.transcriptMessageId)
|
|
const title = result.title.trim() || t('sidebar.newSession')
|
|
useTabStore.getState().openTab(result.sessionId, title)
|
|
useChatStore.getState().connectToSession(result.sessionId)
|
|
addToast({
|
|
type: 'success',
|
|
message: t('chat.branchSuccess', { title }),
|
|
})
|
|
} catch (error) {
|
|
addToast({
|
|
type: 'error',
|
|
message: t('chat.branchError', { detail: getApiErrorMessage(error) }),
|
|
})
|
|
} finally {
|
|
setBranchingMessageId(null)
|
|
}
|
|
}, [addToast, branchSession, branchingMessageId, resolvedSessionId, t])
|
|
|
|
// Pre-compute per-message branchAction + toolResult lookups so MessageBlock's
|
|
// memo barrier is not broken by inline object literals on every render.
|
|
const branchActionByMessageId = useMemo(() => {
|
|
if (branchableMessageTargets.size === 0) {
|
|
return new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
|
|
}
|
|
const result = new Map<string, { label: string; loading: boolean; onBranch: () => void }>()
|
|
const label = t('chat.branchFromHere')
|
|
for (const [uiMessageId, target] of branchableMessageTargets) {
|
|
result.set(uiMessageId, {
|
|
label,
|
|
loading: branchingMessageId === target.uiMessageId,
|
|
onBranch: () => { void handleBranchMessage(target) },
|
|
})
|
|
}
|
|
return result
|
|
}, [branchableMessageTargets, branchingMessageId, handleBranchMessage, t])
|
|
|
|
const { editActionByMessageId, dialog: editResendDialog } = useUserMessageEditResend({
|
|
sessionId: resolvedSessionId,
|
|
messages,
|
|
turnCards: visibleTurnChangeCards,
|
|
disabled: branchActionsDisabled || isSideChatSession || Boolean(turnUndoConfirmTargetId),
|
|
rewindingTurnId,
|
|
setRewindingTurnId,
|
|
t,
|
|
})
|
|
|
|
const toolResultByToolUseId = useMemo(() => {
|
|
if (toolResultMap.size === 0) return new Map<string, { content: unknown; isError: boolean }>()
|
|
const result = new Map<string, { content: unknown; isError: boolean }>()
|
|
for (const [toolUseId, toolResult] of toolResultMap) {
|
|
result.set(toolUseId, { content: toolResult.content, isError: toolResult.isError })
|
|
}
|
|
return result
|
|
}, [toolResultMap])
|
|
|
|
const handleNavigateToConversationItem = useCallback((
|
|
item: ConversationNavigationItem,
|
|
options: { toolUseId?: string } = {},
|
|
) => {
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
|
|
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
|
userScrollIntentUntilRef.current = 0
|
|
setProgrammaticNavigationItemId(item.id)
|
|
setHighlightedNavigationItemKey(item.renderItemKey)
|
|
// A tool call inside the item: its group opens onto it and the call itself
|
|
// takes the highlight, since the item is mostly the rows around it.
|
|
setHighlightedNavigationToolUseId(options.toolUseId ?? null)
|
|
|
|
const scheduleHighlightClear = () => {
|
|
if (navigationHighlightTimerRef.current !== null) {
|
|
window.clearTimeout(navigationHighlightTimerRef.current)
|
|
}
|
|
navigationHighlightTimerRef.current = window.setTimeout(() => {
|
|
setHighlightedNavigationItemKey((current) => current === item.renderItemKey ? null : current)
|
|
navigationHighlightTimerRef.current = null
|
|
}, 1400)
|
|
}
|
|
|
|
const targetScrollTop = getConversationNavigationTargetScrollTop(
|
|
item,
|
|
virtualTranscriptWindow.offsets,
|
|
viewportHeight,
|
|
virtualTranscriptWindow.totalHeight,
|
|
)
|
|
const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
|
const isNearby = Math.abs(container.scrollTop - targetScrollTop) <= viewportHeight * 1.25
|
|
|
|
shouldAutoScrollRef.current = false
|
|
setIsAwayFromLatest(true)
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
ignoreProgrammaticScrollTopRef.current = targetScrollTop
|
|
|
|
if (isNearby && !prefersReducedMotion && typeof container.scrollTo === 'function') {
|
|
container.scrollTo({ top: targetScrollTop, behavior: 'smooth' })
|
|
} else {
|
|
setScrollTopWithoutLayoutRead(container, targetScrollTop)
|
|
}
|
|
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
|
|
|
|
const alignTarget = (retriesLeft: number) => {
|
|
const itemNode = Array.from(
|
|
scrollContentRef.current?.querySelectorAll<HTMLElement>('[data-chat-render-item-key]') ?? [],
|
|
).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey)
|
|
// Align the call itself, not the top of a group that may be thousands of
|
|
// rows tall. The group opens in the render this navigation triggered, so
|
|
// give it one more frame if the row is not in the DOM yet.
|
|
const toolNode = options.toolUseId && itemNode
|
|
? Array.from(itemNode.querySelectorAll<HTMLElement>('[data-tool-use-id]'))
|
|
.find((node) => node.dataset.toolUseId === options.toolUseId)
|
|
: undefined
|
|
if (options.toolUseId && itemNode && !toolNode && retriesLeft > 0) {
|
|
requestAnimationFrame(() => alignTarget(retriesLeft - 1))
|
|
return
|
|
}
|
|
const targetNode = toolNode ?? itemNode
|
|
|
|
if (targetNode) {
|
|
const targetRect = targetNode.getBoundingClientRect()
|
|
const containerRect = container.getBoundingClientRect()
|
|
if (targetRect.height > 0) {
|
|
const correction = targetRect.top - containerRect.top - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO
|
|
if (Math.abs(correction) >= 1) {
|
|
setScrollTopWithoutLayoutRead(container, container.scrollTop + correction)
|
|
syncVirtualViewportFromContainer(container)
|
|
}
|
|
}
|
|
}
|
|
|
|
scheduleHighlightClear()
|
|
}
|
|
requestAnimationFrame(() => alignTarget(1))
|
|
}, [
|
|
syncVirtualViewportFromContainer,
|
|
virtualTranscriptWindow.offsets,
|
|
virtualTranscriptWindow.totalHeight,
|
|
virtualViewport.viewportHeight,
|
|
])
|
|
|
|
const navigateToConversationFindMatch = useCallback((match: ConversationFindMatch) => {
|
|
const container = scrollContainerRef.current
|
|
if (!container) return
|
|
|
|
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
|
const targetOffset = virtualTranscriptWindow.offsets[match.renderIndex] ?? virtualTranscriptWindow.totalHeight
|
|
const targetScrollTop = clampNumber(
|
|
targetOffset - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO,
|
|
0,
|
|
Math.max(0, virtualTranscriptWindow.totalHeight - viewportHeight),
|
|
)
|
|
|
|
setActiveConversationFindMatch(match)
|
|
shouldAutoScrollRef.current = false
|
|
setIsAwayFromLatest(true)
|
|
ignoreProgrammaticScrollUntilRef.current = performance.now() + 250
|
|
ignoreProgrammaticScrollTopRef.current = targetScrollTop
|
|
setScrollTopWithoutLayoutRead(container, targetScrollTop)
|
|
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
|
|
}, [virtualTranscriptWindow.offsets, virtualTranscriptWindow.totalHeight, virtualViewport.viewportHeight])
|
|
const navigateToConversationFindMatchRef = useRef(navigateToConversationFindMatch)
|
|
navigateToConversationFindMatchRef.current = navigateToConversationFindMatch
|
|
const conversationFindRenderItemsRef = useRef(renderItems)
|
|
conversationFindRenderItemsRef.current = renderItems
|
|
const conversationFindStreamingTextRef = useRef(streamingText)
|
|
conversationFindStreamingTextRef.current = streamingText
|
|
const conversationFindControllerRef = useRef<ConversationFindController | null>(null)
|
|
|
|
// "Locate in chat" from the trajectory view. The request arrives together
|
|
// with the switch back to chat, so wait two frames for the list to have real
|
|
// dimensions again before measuring offsets.
|
|
const trajectoryNav = useTrajectoryViewStore((state) => (
|
|
state.nav?.to === 'chat' && state.nav.sessionId === resolvedSessionId ? state.nav : null
|
|
))
|
|
const consumeTrajectoryNav = useTrajectoryViewStore((state) => state.consumeNav)
|
|
const handleNavigateToConversationItemRef = useRef(handleNavigateToConversationItem)
|
|
handleNavigateToConversationItemRef.current = handleNavigateToConversationItem
|
|
useEffect(() => {
|
|
if (trajectoryNav?.to !== 'chat') return
|
|
let frame = requestAnimationFrame(() => {
|
|
frame = requestAnimationFrame(() => {
|
|
consumeTrajectoryNav(trajectoryNav.nonce)
|
|
const items = conversationFindRenderItemsRef.current
|
|
const { index, toolUseId } = findChatRenderTarget(items, trajectoryNav.target)
|
|
if (index < 0) return
|
|
const key = getRenderItemKey(items[index]!)
|
|
handleNavigateToConversationItemRef.current(
|
|
{ id: key, renderItemKey: key, renderIndex: index, turnNumber: 0, preview: '', attachmentCount: 0 },
|
|
{ toolUseId },
|
|
)
|
|
})
|
|
})
|
|
return () => cancelAnimationFrame(frame)
|
|
}, [consumeTrajectoryNav, trajectoryNav])
|
|
|
|
useEffect(() => {
|
|
if (!resolvedSessionId || resolvedSessionId !== activeTabId) return
|
|
|
|
const controller: ConversationFindController = {
|
|
search(query, preferredIndex = 0) {
|
|
const matches = findConversationMatches(
|
|
conversationFindRenderItemsRef.current,
|
|
conversationFindStreamingTextRef.current,
|
|
query,
|
|
)
|
|
conversationFindMatchesRef.current = matches
|
|
const selectedMatch = matches[Math.min(preferredIndex, Math.max(0, matches.length - 1))]
|
|
if (selectedMatch) {
|
|
navigateToConversationFindMatchRef.current(selectedMatch)
|
|
} else {
|
|
setActiveConversationFindMatch(null)
|
|
clearConversationFindHighlights()
|
|
}
|
|
return matches.length
|
|
},
|
|
navigate(index) {
|
|
const match = conversationFindMatchesRef.current[index]
|
|
if (match) navigateToConversationFindMatchRef.current(match)
|
|
},
|
|
clear() {
|
|
conversationFindMatchesRef.current = []
|
|
setActiveConversationFindMatch(null)
|
|
clearConversationFindHighlights()
|
|
},
|
|
}
|
|
conversationFindControllerRef.current = controller
|
|
const unregister = registerConversationFindController(controller)
|
|
return () => {
|
|
if (conversationFindControllerRef.current === controller) {
|
|
conversationFindControllerRef.current = null
|
|
}
|
|
unregister()
|
|
}
|
|
}, [activeTabId, resolvedSessionId])
|
|
|
|
useEffect(() => {
|
|
const controller = conversationFindControllerRef.current
|
|
if (!controller) return
|
|
const notify = () => {
|
|
conversationFindRefreshTimerRef.current = null
|
|
conversationFindLastRefreshAtRef.current = performance.now()
|
|
notifyConversationFindContentChanged(controller)
|
|
}
|
|
if (conversationFindRefreshTimerRef.current !== null) return
|
|
const remainingDelay = CONVERSATION_FIND_CONTENT_REFRESH_MS -
|
|
(performance.now() - conversationFindLastRefreshAtRef.current)
|
|
if (remainingDelay <= 0) {
|
|
notify()
|
|
return
|
|
}
|
|
conversationFindRefreshTimerRef.current = window.setTimeout(notify, remainingDelay)
|
|
}, [renderItems, streamingText])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!activeConversationFindMatch) {
|
|
clearConversationFindHighlights()
|
|
return
|
|
}
|
|
|
|
const root = scrollContentRef.current
|
|
if (!root) return
|
|
paintConversationFindHighlights(root, activeConversationFindMatch)
|
|
}, [activeConversationFindMatch, virtualTranscriptWindow.items])
|
|
|
|
const restoreWorkspaceOrigin = useCallback((origin: WorkspaceOrigin, attempt = 0) => {
|
|
const container = scrollContainerRef.current
|
|
const content = scrollContentRef.current
|
|
if (!container || !content || !resolvedSessionId) return
|
|
|
|
const renderItem = [...content.querySelectorAll<HTMLElement>('[data-chat-render-item-key]')]
|
|
.find((node) => node.dataset.chatRenderItemKey === origin.sourceTurnKey)
|
|
const opener = renderItem
|
|
? [...renderItem.querySelectorAll<HTMLElement>('[id]')]
|
|
.find((node) => node.id === origin.sourceElementId)
|
|
?? (origin.sourceElementId.startsWith('turn-change-opener-')
|
|
? renderItem.querySelector<HTMLElement>('[data-turn-change-disclosure="true"]')
|
|
: null)
|
|
: null
|
|
|
|
if (renderItem && opener) {
|
|
if (!isRenderItemFullyVisibleInChatScroller(renderItem)) {
|
|
renderItem.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
opener.focus({ preventScroll: true })
|
|
useWorkspaceStore.getState().setOrigin(resolvedSessionId, null)
|
|
workspaceOriginRestoreFrameRef.current = null
|
|
return
|
|
}
|
|
|
|
const renderIndex = renderItemKeys.indexOf(origin.sourceTurnKey)
|
|
if (!renderItem && renderIndex >= 0) {
|
|
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
|
|
const targetScrollTop = clampNumber(
|
|
(virtualTranscriptWindow.offsets[renderIndex] ?? 0) - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO,
|
|
0,
|
|
Math.max(0, virtualTranscriptWindow.totalHeight - viewportHeight),
|
|
)
|
|
shouldAutoScrollRef.current = false
|
|
setScrollTopWithoutLayoutRead(container, targetScrollTop)
|
|
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
|
|
}
|
|
|
|
if (attempt >= 7 || renderIndex < 0) {
|
|
useWorkspaceStore.getState().setOrigin(resolvedSessionId, null)
|
|
workspaceOriginRestoreFrameRef.current = null
|
|
return
|
|
}
|
|
|
|
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
|
|
restoreWorkspaceOrigin(origin, attempt + 1)
|
|
})
|
|
}, [
|
|
renderItemKeys,
|
|
resolvedSessionId,
|
|
virtualTranscriptWindow.offsets,
|
|
virtualTranscriptWindow.totalHeight,
|
|
virtualViewport.viewportHeight,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (workspaceOriginSessionRef.current !== resolvedSessionId) {
|
|
workspaceOriginSessionRef.current = resolvedSessionId
|
|
if (workspaceOriginRestoreFrameRef.current !== null) {
|
|
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
|
workspaceOriginRestoreFrameRef.current = null
|
|
}
|
|
}
|
|
if (isWorkspacePanelOpen) {
|
|
if (workspaceOriginRestoreFrameRef.current !== null) {
|
|
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
|
|
workspaceOriginRestoreFrameRef.current = null
|
|
}
|
|
return
|
|
}
|
|
if (!workspacePanelOrigin || workspaceOriginRestoreFrameRef.current !== null) return
|
|
|
|
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
|
|
workspaceOriginRestoreFrameRef.current = null
|
|
restoreWorkspaceOrigin(workspacePanelOrigin)
|
|
})
|
|
}, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspaceOrigin, workspacePanelOrigin])
|
|
|
|
|
|
/** Only the item a jump landed on hears about it, so other memoized items keep their props. */
|
|
const navigationToolUseIdFor = (item: RenderItem): string | undefined => (
|
|
highlightedNavigationToolUseId !== null && highlightedNavigationItemKey === getRenderItemKey(item)
|
|
? highlightedNavigationToolUseId
|
|
: undefined
|
|
)
|
|
|
|
const renderTranscriptItem = (item: RenderItem, index: number) => {
|
|
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
|
|
|
|
return (
|
|
<>
|
|
<RenderItemBoundary>
|
|
{item.kind === 'tool_group' ? (
|
|
<ToolCallGroup
|
|
sessionId={resolvedSessionId}
|
|
onOpenAgentRun={onOpenAgentRun}
|
|
resolveAgentActivityTarget={resolveAgentActivityTarget}
|
|
toolCalls={item.toolCalls}
|
|
steps={item.steps}
|
|
resultMap={toolResultMap}
|
|
childToolCallsByParent={childToolCallsByParent}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
agentTaskStatuses={agentTaskStatuses}
|
|
activeThinkingId={activeThinkingId}
|
|
isStreaming={
|
|
chatState === 'tool_executing' &&
|
|
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
|
|
}
|
|
// Only the tail of a live turn can still grow. Everything above it
|
|
// is finished, whatever any individual tool's state looks like this
|
|
// instant — which is why this, and not `isStreaming`, decides
|
|
// whether a run stands open.
|
|
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
|
|
disclosureKey={getRenderItemKey(item)}
|
|
revealToolUseId={navigationToolUseIdFor(item)}
|
|
/>
|
|
) : item.kind === 'team_card' ? (
|
|
resolvedSessionId ? (() => {
|
|
const cardSnapshot = snapshotForTeamCard(teamSnapshot, item)
|
|
const fallbackPhase = item.endedAt !== undefined || teamTaskWindows.some((window) => (
|
|
item.startedAt >= window.startedAt &&
|
|
window.endedAt !== undefined &&
|
|
item.startedAt <= window.endedAt
|
|
)) ? 'completed' : 'forming'
|
|
return (
|
|
<AgentTeamsInlineCard
|
|
snapshot={cardSnapshot}
|
|
teamName={item.teamName}
|
|
fallbackPhase={fallbackPhase}
|
|
phaseOverride={item.endedAt !== undefined ? 'completed' : undefined}
|
|
onOpen={cardSnapshot
|
|
? () => openTeamWorkbench(resolvedSessionId, cardSnapshot.team.name)
|
|
: undefined}
|
|
>
|
|
<TeamCoordinationAudit toolCalls={item.coordinationToolCalls} />
|
|
</AgentTeamsInlineCard>
|
|
)
|
|
})() : null
|
|
) : (
|
|
<MessageBlock
|
|
sessionId={resolvedSessionId}
|
|
message={item.message}
|
|
team={memberSessionTeam ?? undefined}
|
|
activeThinkingId={activeThinkingId}
|
|
agentTaskNotifications={agentTaskNotifications}
|
|
toolResult={
|
|
item.message.type === 'tool_use'
|
|
? toolResultByToolUseId.get(item.message.toolUseId) ?? null
|
|
: null
|
|
}
|
|
branchAction={branchActionByMessageId.get(item.message.id)}
|
|
editAction={editActionByMessageId.get(item.message.id)}
|
|
turnChangedFiles={changedFilesByRenderIndex.get(index)}
|
|
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
|
|
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
|
|
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
|
|
navigationHighlighted={
|
|
item.message.type === 'tool_use' &&
|
|
navigationToolUseIdFor(item) === item.message.toolUseId
|
|
}
|
|
/>
|
|
)}
|
|
</RenderItemBoundary>
|
|
|
|
|
|
{resolvedSessionId && cardsForItem.map((card) => {
|
|
const error = turnActionErrors[card.target.messageId] ?? null
|
|
const openUndoDialog = () => {
|
|
setTurnUndoConfirmTargetId(card.target.messageId)
|
|
}
|
|
if (!card.checkpoint.code.available) {
|
|
return (
|
|
<div
|
|
key={`conversation-rewind-${card.target.messageId}`}
|
|
className="mt-1 flex flex-wrap items-center gap-2 px-1"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
loading={rewindingTurnId === card.target.messageId}
|
|
onClick={openUndoDialog}
|
|
icon={<Undo2 size={13} strokeWidth={2} aria-hidden="true" />}
|
|
>
|
|
{t('chat.conversationRewindAction')}
|
|
</Button>
|
|
{error ? (
|
|
<span role="alert" className="text-xs text-[var(--color-error)]">
|
|
{error}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<CurrentTurnChangeCard
|
|
key={`turn-change-${card.target.messageId}`}
|
|
sessionId={resolvedSessionId}
|
|
expanded={expandedChangeCards[`${resolvedSessionId}:${card.target.messageId}`] ?? false}
|
|
onExpandedChange={(expanded) => setExpandedChangeCards((current) => ({
|
|
...current,
|
|
[`${resolvedSessionId}:${card.target.messageId}`]: expanded,
|
|
}))}
|
|
checkpoint={card.checkpoint}
|
|
workDir={card.workDir}
|
|
error={error}
|
|
isUndoing={rewindingTurnId === card.target.messageId}
|
|
isLatest={card.isLatest}
|
|
onUndo={openUndoDialog}
|
|
/>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div ref={messageListRef} data-testid="message-list" className="relative min-h-0 flex-1">
|
|
<div
|
|
ref={scrollContainerRef}
|
|
onScroll={updateAutoScrollState}
|
|
// Native scroll anchoring carries position across content growth; the
|
|
// chat timeline mounts one array, so no page-boundary compensation is
|
|
// needed and disabling the browser anchor only adds a frame of jump.
|
|
onClickCapture={handleDisclosureToggle}
|
|
onWheel={handleWheelScrollIntent}
|
|
onPointerDown={markUserScrollIntent}
|
|
onTouchStart={markUserScrollIntent}
|
|
onKeyDown={handleKeyDownScrollIntent}
|
|
className={`${CHAT_SCROLL_AREA_CLASS} h-full overflow-y-auto ${chatScrollPaddingClass}`}
|
|
>
|
|
<div
|
|
ref={scrollContentRef}
|
|
// The reading measure holds whether or not a right-hand panel is
|
|
// open — `compact` only tightens padding. Dropping to `max-w-full`
|
|
// was what made the transcript lose its centred structure the moment
|
|
// the agent-teams workbench appeared.
|
|
className="mx-auto max-w-[var(--chat-content-max-width)]"
|
|
>
|
|
{sessionState?.historyWindowed && sessionState?.historyPage?.nextCursor ? (
|
|
// The server's byte budget cut history short; this is the only
|
|
// remaining pagination seam, and it is user-driven.
|
|
<div className="flex items-center justify-center gap-2 py-2 text-xs text-[var(--color-text-tertiary)]">
|
|
{sessionState.historyPageLoading ? (
|
|
<div className="flex items-center gap-2" role="status">
|
|
<LoaderCircle size={12} className="animate-spin" />{t('chat.history.loading')}
|
|
</div>
|
|
) : sessionState.historyError ? (
|
|
<>
|
|
<span role="alert" className="text-[var(--color-text-secondary)]">{t('chat.history.loadFailed')}</span>
|
|
<Button variant="ghost" size="xs" onClick={() => resolvedSessionId && void loadOlderHistory(resolvedSessionId)}>{t('chat.history.retry')}</Button>
|
|
</>
|
|
) : (
|
|
<Button variant="ghost" size="xs" onClick={() => resolvedSessionId && void loadOlderHistory(resolvedSessionId)}>{t('chat.history.loadMore')}</Button>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
{virtualTranscriptWindow.enabled ? (
|
|
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
|
|
) : null}
|
|
|
|
{virtualTranscriptWindow.items.map(({ item, index }) => {
|
|
const itemKey = getRenderItemKey(item)
|
|
const mountKey = groupMountKeys.keys.get(itemKey) ?? itemKey
|
|
const content = renderTranscriptItem(item, index)
|
|
const railPosition = turnRailPositions[index] ?? 'none'
|
|
|
|
return virtualTranscriptWindow.enabled ? (
|
|
<MeasuredRenderItem
|
|
key={mountKey}
|
|
itemKey={itemKey}
|
|
onHeightChange={handleVirtualItemHeightChange}
|
|
highlighted={highlightedNavigationItemKey === itemKey}
|
|
railPosition={railPosition}
|
|
>
|
|
{content}
|
|
</MeasuredRenderItem>
|
|
) : (
|
|
<div
|
|
key={mountKey}
|
|
data-chat-render-item-key={itemKey}
|
|
data-turn-rail={railPosition}
|
|
className={`${CHAT_RENDER_ITEM_CLASS} chat-render-item--cv ${turnRailClass(railPosition)} ${highlightedNavigationItemKey === itemKey ? 'chat-render-item--navigation-target' : ''}`}
|
|
>
|
|
{content}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{virtualTranscriptWindow.enabled ? (
|
|
<VirtualSpacer height={virtualTranscriptWindow.afterHeight} position="bottom" />
|
|
) : null}
|
|
|
|
{streamingText.trim() && (
|
|
<div
|
|
data-chat-render-item-key={STREAMING_ASSISTANT_NAVIGATION_KEY}
|
|
data-turn-rail={streamingRailPosition}
|
|
className={turnRailClass(streamingRailPosition)}
|
|
>
|
|
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
|
|
</div>
|
|
)}
|
|
|
|
{chatState === 'compacting' && !hasCompactingDivider && (
|
|
<CompactStatusDivider state="compacting" />
|
|
)}
|
|
|
|
{/* Show StreamingIndicator when:
|
|
- tool_executing: background work is running
|
|
- thinking but no active ThinkingBlock yet: the gap between
|
|
sending a message and receiving the first thinking delta
|
|
The live status stays in the transcript, next to the output it is
|
|
describing — it is part of the conversation, not composer chrome. */}
|
|
{showsTurnStatusLine && (
|
|
// On the rail, so the lit line runs all the way down to the status
|
|
// it explains — except while preparing a turn, when no transcript
|
|
// item exists yet and the status has to stand on its own.
|
|
<div className={renderItems.length === 0 ? undefined : turnRailClass('end')}>
|
|
<StreamingIndicator />
|
|
</div>
|
|
)}
|
|
|
|
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
|
|
<div className="mx-auto mb-5 w-full max-w-[var(--chat-content-max-width)] rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
|
|
{turnChangeLoadError}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoadingTurnChangeCards && chatState === 'idle' && !hasRunningBackgroundTasks &&
|
|
workspaceChangesFallback?.sessionId === resolvedSessionId ? (
|
|
<WorkspaceChangesFallback
|
|
key={workspaceChangesFallback.sessionId}
|
|
sessionId={workspaceChangesFallback.sessionId}
|
|
files={workspaceChangesFallback.files}
|
|
/>
|
|
) : null}
|
|
|
|
<div />
|
|
</div>
|
|
</div>
|
|
|
|
{showConversationNavigator ? (
|
|
<ConversationNavigator
|
|
mode={conversationNavigationMode}
|
|
items={conversationNavigationItems}
|
|
activeItemId={visibleConversationNavigationItemId}
|
|
onNavigate={handleNavigateToConversationItem}
|
|
/>
|
|
) : null}
|
|
|
|
{isAwayFromLatest && (
|
|
<Button
|
|
variant="secondary"
|
|
size="md"
|
|
onClick={handleJumpToLatest}
|
|
title={t('chat.jumpToLatest')}
|
|
aria-label={t('chat.jumpToLatest')}
|
|
// `glass-panel` is unlayered CSS, so it wins over the variant's
|
|
// layered background/border utilities without a tailwind-merge.
|
|
className="glass-panel absolute bottom-4 right-5 z-20 rounded-full text-[13.5px] font-medium hover:-translate-y-px motion-reduce:hover:translate-y-0"
|
|
icon={<ArrowDown size={15} aria-hidden="true" />}
|
|
>
|
|
{t('chat.jumpToLatest')}
|
|
</Button>
|
|
)}
|
|
|
|
<ActionDialog
|
|
open={Boolean(confirmTurnCard)}
|
|
onClose={() => {
|
|
if (!rewindingTurnId) {
|
|
setTurnUndoConfirmTargetId(null)
|
|
}
|
|
}}
|
|
title={confirmTurnCard?.isLatest
|
|
? t('chat.turnChangesLatestConfirmTitle')
|
|
: t('chat.turnChangesHistoricalConfirmTitle')}
|
|
body={confirmBody}
|
|
actions={confirmActions}
|
|
width={520}
|
|
loading={Boolean(rewindingTurnId)}
|
|
/>
|
|
<ActionDialog
|
|
open={editResendDialog.open}
|
|
onClose={editResendDialog.onClose}
|
|
title={editResendDialog.title}
|
|
body={editResendDialog.body}
|
|
actions={editResendDialog.actions}
|
|
width={520}
|
|
loading={editResendDialog.loading}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const MessageBlock = memo(function MessageBlock({
|
|
sessionId,
|
|
message,
|
|
team,
|
|
activeThinkingId,
|
|
agentTaskNotifications,
|
|
toolResult,
|
|
branchAction,
|
|
editAction,
|
|
turnChangedFiles,
|
|
isTurnOutputOwner,
|
|
turnCompletion,
|
|
supersededAskUserQuestionIds,
|
|
navigationHighlighted = false,
|
|
}: {
|
|
sessionId?: string | null
|
|
message: UIMessage
|
|
team?: TeamDetail
|
|
activeThinkingId: string | null
|
|
agentTaskNotifications: Record<string, AgentTaskNotification>
|
|
toolResult?: { content: unknown; isError: boolean } | null
|
|
branchAction?: {
|
|
label: string
|
|
loading?: boolean
|
|
onBranch: () => void
|
|
}
|
|
editAction?: UserMessageEditAction
|
|
turnChangedFiles?: string[]
|
|
isTurnOutputOwner?: boolean
|
|
turnCompletion?: TurnCompletion
|
|
supersededAskUserQuestionIds?: ReadonlySet<string>
|
|
/** A "locate in chat" jump landed on this tool call. */
|
|
navigationHighlighted?: boolean
|
|
}) {
|
|
const t = useTranslation()
|
|
const teammateVisual = message.type === 'user_text' && message.teammateFrom && team
|
|
? (() => {
|
|
const { member, isLead } = resolveTeamMemberIdentity(team, message.teammateFrom)
|
|
const avatarKey = getMemberAvatarKey(member, isLead)
|
|
const memberIndex = Math.max(0, team.members.findIndex((candidate) => candidate.agentId === member.agentId))
|
|
return {
|
|
avatarKey,
|
|
avatarSrc: MEMBER_AVATARS[avatarKey],
|
|
accent: memberAccentColor(member.color, memberIndex),
|
|
}
|
|
})()
|
|
: null
|
|
|
|
switch (message.type) {
|
|
case 'user_text':
|
|
return (
|
|
<SelectableChatMessage
|
|
sessionId={sessionId}
|
|
messageId={message.id}
|
|
role="user"
|
|
content={message.content}
|
|
>
|
|
<UserMessage
|
|
content={message.content}
|
|
attachments={message.attachments}
|
|
sessionReferences={message.sessionReferences}
|
|
collaboration={message.collaboration}
|
|
branchAction={branchAction}
|
|
editAction={editAction}
|
|
timestamp={message.timestamp}
|
|
sessionId={sessionId ?? undefined}
|
|
teammateFrom={message.teammateFrom}
|
|
teammateAvatarSrc={teammateVisual?.avatarSrc}
|
|
teammateAvatarKey={teammateVisual?.avatarKey}
|
|
teammateAccent={teammateVisual?.accent}
|
|
/>
|
|
</SelectableChatMessage>
|
|
)
|
|
case 'assistant_text':
|
|
return (
|
|
<SelectableChatMessage
|
|
sessionId={sessionId}
|
|
messageId={message.id}
|
|
role="assistant"
|
|
content={message.content}
|
|
>
|
|
<AssistantMessage
|
|
content={message.content}
|
|
branchAction={branchAction}
|
|
sessionId={sessionId ?? undefined}
|
|
turnChangedFiles={turnChangedFiles}
|
|
isTurnOutputOwner={isTurnOutputOwner}
|
|
turnCompletion={turnCompletion}
|
|
/>
|
|
</SelectableChatMessage>
|
|
)
|
|
case 'thinking':
|
|
// No wrapper padding: the row's own `-mx-2 … px-2` already lands its text
|
|
// on the column's left edge, the same as one inside a run.
|
|
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} disclosureKey={message.id} />
|
|
case 'tool_use':
|
|
if (message.toolName === 'AskUserQuestion' && !message.isPending) {
|
|
return (
|
|
<AskUserQuestion
|
|
sessionId={sessionId}
|
|
toolUseId={message.toolUseId}
|
|
input={message.input}
|
|
result={toolResult?.content}
|
|
supersededByUserMessage={supersededAskUserQuestionIds?.has(message.toolUseId)}
|
|
/>
|
|
)
|
|
}
|
|
// No durationMs prop here on purpose: buildRenderModel only emits a
|
|
// standalone tool_use item for AskUserQuestion, and this branch is reached
|
|
// only while such a call is still pending — so there is never a result to
|
|
// measure against. The badge is wired in ToolCallGroup, the path every
|
|
// other tool call takes.
|
|
return (
|
|
<ToolCallBlock
|
|
toolName={message.toolName}
|
|
input={message.input}
|
|
result={toolResult}
|
|
isPending={message.isPending}
|
|
status={message.status}
|
|
partialInput={message.partialInput}
|
|
disclosureKey={message.toolUseId}
|
|
toolUseId={message.parentToolUseId ? undefined : message.toolUseId}
|
|
navigationHighlighted={navigationHighlighted}
|
|
agentTaskNotification={
|
|
message.toolName === 'Agent'
|
|
? agentTaskNotifications[message.toolUseId]
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
case 'tool_result':
|
|
return (
|
|
<ToolResultBlock
|
|
content={message.content}
|
|
isError={message.isError}
|
|
standalone
|
|
disclosureKey={message.id}
|
|
/>
|
|
)
|
|
case 'permission_request':
|
|
return (
|
|
<PermissionDialog
|
|
sessionId={sessionId}
|
|
requestId={message.requestId}
|
|
toolName={message.toolName}
|
|
input={message.input}
|
|
description={message.description}
|
|
displayName={message.displayName}
|
|
/>
|
|
)
|
|
case 'error': {
|
|
const businessErrorKey = message.businessErrorCode
|
|
? `businessError.${message.businessErrorCode}` as TranslationKey
|
|
: null
|
|
const businessErrorText = businessErrorKey ? t(businessErrorKey) : null
|
|
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
|
|
const errorText = errorKey ? t(errorKey) : null
|
|
const displayMessage =
|
|
businessErrorText && businessErrorText !== businessErrorKey
|
|
? businessErrorText
|
|
: (errorText && errorText !== errorKey)
|
|
? errorText
|
|
: message.message
|
|
const showRawDetail =
|
|
!message.businessErrorCode &&
|
|
Boolean(message.message) &&
|
|
message.message.trim() !== '' &&
|
|
message.message !== displayMessage
|
|
return (
|
|
<div className="mb-3 px-4 py-2.5 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] text-sm text-[var(--color-on-error-container)]">
|
|
<strong>{t('common.error')}:</strong> {displayMessage}
|
|
{showRawDetail && (
|
|
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]">
|
|
{message.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
case 'task_summary':
|
|
return <InlineTaskSummary tasks={message.tasks} />
|
|
case 'memory_event':
|
|
return <MemoryEventCard message={message} />
|
|
case 'compact_summary':
|
|
return <CompactStatusDivider message={message} state={message.phase === 'compacting' ? 'compacting' : 'complete'} />
|
|
case 'goal_event':
|
|
return message.action === 'status' && message.status === 'continuing'
|
|
? <GoalContinuationDivider message={message} />
|
|
: <GoalEventCard message={message} />
|
|
case 'background_task':
|
|
return <BackgroundTaskEventCard message={message} />
|
|
case 'system':
|
|
return (
|
|
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
|
|
{message.content}
|
|
</div>
|
|
)
|
|
}
|
|
})
|