refactor(chat): complete turn-based pipeline and stabilize streaming, scroll, and tool UX (#629)

* refactor: add canonical turn projection pipeline for chat

Centralizes turn/activity/summary derivation, migrates render consumers to shared projection sources, and adds projector regression coverage for retries, malformed parent fallbacks, and activity segmentation.

* refactor: stream assistant text live in turn-first render path

* refactor: complete phase-3 turn timeline history/navigation

* refactor: complete phase-4 turn pipeline cutover

Remove legacy grouping adapters and lock key edge cases with executable regression tests so turn-centric rendering stays stable across retry/history/navigation flows.

* fix: align chat history loading path with opencode parity

* fix: stabilize chat streaming and reduce render churn

* feat: add blurIn streaming text animation and smooth lerp-based auto-scroll

Streaming markdown now uses Streamdown blurIn animation (150ms, ease-out) for per-word reveal
Auto-scroll during streaming uses continuous exponential smoothing (lerp) instead of restarting spring animations
One-shot scroll-to-bottom (button click) uses motion spring for natural deceleration

* fix: respect user scroll-up during streaming and re-pin on scroll back to bottom

* fix: stabilize activity stream and tool progress rendering

Activity now stays chronologically consistent and avoids duplicate reasoning/justification.
Tool rows show running state and duration earlier with smoother pending behavior.
Removed legacy trim/timestamp paths to prevent message drops and simplify Activity UI.

* fix: reverted regression of revert functionality

* fix: stabilize chat activity layout and tool progress behavior

Stopped user messages from re-animating when new user messages appear.
Made Activity and aggregated tool rows wrap inline consistently without misaligned headers/icons.
Fixed tool part matching so duration and shine no longer reset across pending/in-progress status updates.

* fix: restore activity mode behavior and stabilize tool rendering

Re-enabled Collapsed/Summary/Detailed/Changes defaults for Activity and tool expansion.
Fixed tool row wrapping/alignment, removed user fade re-animations, and disabled FadeInOnReveal animations.
Improved tool status handling to reduce timer/shine resets and made reasoning bold render as normal text.

* refactor: simplify chat tool and message rendering

Replace activity-group rendering with flat inline part rendering
Remove obsolete tool output and justification activity settings
Keep user message animation and improve edit/apply-patch file path headers

* style: narrow chat and composer columns equally

* fix: Tool status checks and abort flow regression

* fix: improve multi-file apply patch header rendering

* fix: make timeline message jump scroll to selected chat entry

Conversation Timeline clicks now reliably scroll to the selected message in chat
Timeline dialog waits for navigation result before closing to avoid silent failures
Chat message scrolling now resolves the active chat scroller consistently

* chore: remove branch-added chat tests and clean lint leftovers

Removes newly added chat and message test files from this branch
Fixes lint blockers in ProgressiveGroup, ToolPart, and messageStore
Keeps type-check, lint, and build passing after cleanup

* fix: keep composer status row closest to the input

Status/todo row now renders below queued messages and attachments.
Linked GitHub issue/PR chips stay above status so input context order is consistent.
Improves chat composer readability while assistant is working.

* fix: disable assistant header animations and polish chat status behavior

Assistant message header now renders without transition effects
Removed a lint issue from the working placeholder props
Chat status and message flow updates improve consistency during active turns

* feat: increase bottom spacer in mobile chat for better scrolling

* feat: add sorted chat mode with progressive activity rendering

Adds global chat render mode settings for sorted and live behavior
Renders non-stop assistant output in Activity as messages complete
Disables streaming text animation in sorted mode and restores classic reasoning/justification blocks

* fix: stabilize chat timeline navigation and older history loading

Prevents auto-loading older history from scroll and keeps loading behind the button
Fixes timeline jump-to-message behavior that snapped back to the bottom
Reduces chat list flicker and empty-state glitches on large session pagination

* fix: stabilize sorted activity text and hide reasoning timers

Prevented sorted justification from appearing before message completion by requiring explicit non-stop finish.
Removed duration values for sorted Thinking and Justification blocks.
Kept smooth sorted auto-follow behavior tuned to reduce bottom-jump jitter.

* refactor: simplify chat tool rendering paths and add parts docs

Centralized tool icon mapping into a shared presentation helper used by grouped and expandable rows.
Removed static-tool-specific branches from ToolPart to keep it focused on expandable tools.
Added a new DOCUMENTATION.md explaining where to change tool descriptions and rendering behavior.

* feat: enhance event stream handling for message parts

- Refactor event key building functions to improve clarity and reduce redundancy.
- Introduce `applyPartDelta` method in message store for handling delta updates to message parts.
- Update `useEventStream` to utilize the new `applyPartDelta` method for processing incoming deltas.
- Enhance text extraction logic to accommodate new part types and improve merging behavior.
- Implement logic to skip delta-driven updates for ongoing text/reasoning parts to prevent overwriting.
- Adjust server-side text merging logic to handle trimmed text comparisons and improve consistency.

* feat: enhance OpencodeService with delta handling and stale delta management

- Introduced globalSseStaleDeltas to track stale deltas.
- Added methods to generate keys for delta and updated part events.
- Updated global SSE loop to skip processing stale deltas.
- Refactored global SSE event handling to improve performance and reliability.

refactor: remove unused streaming part handling in messageStore

- Eliminated streaming part queue and associated logic.
- Simplified message merging logic by removing redundant checks.
- Streamlined message handling to improve clarity and maintainability.

chore: clean up unused streaming text normalization functions

- Removed deprecated functions related to streaming text identity and merging.
- Simplified normalizeStreamingTextPayload to focus on current functionality.

* refactor: simplify streaming event passthrough

Use SDK-native global event streaming in the UI client
Remove dead server-side SSE normalization helpers
Keep streaming payloads closer to upstream behavior

* revert: remove message memory settings UI

Drop the restored message memory settings screen
Keep session settings focused on current defaults
Preserve chat history limits as internal behavior

* feat: add compact collapsed Activity preview in sorted mode

Collapsed Activity now shows a live preview of the latest 7 entries
Older activity rows roll up behind a +N more indicator
The +N more indicator is clickable to expand the full Activity

* fix: adjusted activity changed files text prominence

* fix: make sorted mode snap to bottom when not streaming

* perf: show chat scrollbar only on user-driven scrolling

Chat scrollbar now stays hidden during automatic follow-to-bottom
Scrollbar visibility is gated by real user scroll intent
Reduced scroll overhead by removing timer-based intent tracking

* refactor: align web and vscode session activity streaming

Route VS Code SSE through SDK-based proxy flow
Restore controlled web session activity tracking and cooldowns
Keep chat activity behavior consistent across runtimes

* fix: adjusted muted foreground themes color

* fix: stabilize session activity and attention updates

Keep streamed chat state in sync as sessions complete
Restore reliable attention indicators for inactive sessions
Tighten server and UI event handling for chat updates

* feat: add chat render previews and file icon visibility setting

Added animated chat render mode previews in Chat settings.
Added a new toggle to show or hide file icons across tool rows.
Persisted chat render, activity, and mermaid modes to shared settings file.

* fix: update default GitHub OAuth client ID

Use the new OpenChamber org GitHub OAuth app ID by default.
Keep web server and VS Code auth defaults in sync.

* fix: sync chat settings across VS Code chat views

Broadcast settings changes to both sidebar and editor chat webviews.
Apply synced settings in each webview via shared settings refresh.
Ensures chat render mode updates appear immediately in editor tabs.

* fix: stop writing session folders file in VS Code workspaces

Disable disk persistence for session folders in VS Code webview runtime
Keep session folder state in VS Code/webview storage instead of project files
Preserve existing behavior for non-VS Code runtimes

* style: polish expanded tool panels and bash output readability

Removed extra borders/labels in expanded tool cards for a cleaner UI
Added softer scroll shadows and full-height decorative activity line
Switched bash command/output to code-style typography with tighter spacing

* feat: add default-open tool controls for chat activity

Added Chat settings toggles to open Bash and Edit tools by default
Default-open behavior now applies across both sorted and live activity rendering
Users can still manually collapse default-open tools, plus bash output height was reduced

* fix: align chat loading skeleton with message layout

Match skeleton width and horizontal alignment to chat message column
Add one-line tool-style skeleton rows with subtle circular placeholders
Increase top spacing so loading state no longer touches chat header

* fix: prevent blank screen when adding inline diff comments

Sanitize persisted inline comment drafts to ignore invalid data
Harden diff annotations against malformed line numbers
Fail safely when annotation updates error instead of crashing the view

* fix: streamline VS Code archived sessions sidebar

Show archived sessions in VS Code only for the active workspace directory.
Render archived sessions as a flat list in VS Code without project-root subfolders.
Hide the "Open in Side Panel" session action in VS Code.
This commit is contained in:
Bohdan Triapitsyn
2026-03-12 23:45:45 +02:00
committed by GitHub
parent d969ff4af1
commit fab32ca992
134 changed files with 9366 additions and 6147 deletions
@@ -5,9 +5,12 @@ interface FadeInOnRevealProps {
children: React.ReactNode;
className?: string;
skipAnimation?: boolean;
forceAnimation?: boolean;
ignoreContextDisabled?: boolean;
respectReducedMotion?: boolean;
}
const FADE_ANIMATION_ENABLED = true;
const FADE_ANIMATION_ENABLED = false;
// Context to allow parent components (like VirtualMessageList) to disable animations
// for items entering the viewport due to scrolling rather than new content
@@ -19,13 +22,26 @@ export const FadeInDisabledProvider: React.FC<{ disabled: boolean; children: Rea
</FadeInDisabledContext.Provider>
);
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, className, skipAnimation }) => {
export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({
children,
className,
skipAnimation,
forceAnimation = false,
ignoreContextDisabled = false,
respectReducedMotion = false,
}) => {
const contextDisabled = React.useContext(FadeInDisabledContext);
const shouldSkip = skipAnimation || contextDisabled;
const reducedMotion =
respectReducedMotion &&
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const shouldSkip = Boolean(skipAnimation) || (!ignoreContextDisabled && contextDisabled) || reducedMotion;
const animationEnabled = FADE_ANIMATION_ENABLED || forceAnimation;
const [visible, setVisible] = React.useState(shouldSkip);
React.useEffect(() => {
if (!FADE_ANIMATION_ENABLED || shouldSkip) {
if (!animationEnabled || shouldSkip) {
return;
}
@@ -48,9 +64,9 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
window.cancelAnimationFrame(frame);
}
};
}, [shouldSkip]);
}, [animationEnabled, shouldSkip]);
if (!FADE_ANIMATION_ENABLED || shouldSkip) {
if (!animationEnabled || shouldSkip) {
return <>{children}</>;
}
@@ -66,4 +82,3 @@ export const FadeInOnReveal: React.FC<FadeInOnRevealProps> = ({ children, classN
</div>
);
};
@@ -3,12 +3,12 @@ import type { Part } from '@opencode-ai/sdk/v2';
import UserTextPart from './parts/UserTextPart';
import ToolPart from './parts/ToolPart';
import ProgressiveGroup from './parts/ProgressiveGroup';
import AssistantTextPart from './parts/AssistantTextPart';
import ReasoningPart from './parts/ReasoningPart';
import { MessageFilesDisplay } from '../FileAttachment';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase, ToolPopupContent, AgentMentionInfo } from './types';
import type { TurnGroupingContext } from '../hooks/useTurnGrouping';
import type { TurnGroupingContext } from '../lib/turns/types';
import { cn } from '@/lib/utils';
import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
@@ -19,7 +19,6 @@ import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
@@ -32,6 +31,10 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { toPng } from 'html-to-image';
import { toast } from '@/components/ui';
import { formatTimestampForDisplay } from './timeFormat';
import { ToolRevealOnMount } from './parts/ToolRevealOnMount';
import { StaticToolRow } from './parts/ProgressiveGroup';
import { getStaticGroupToolName, isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
type SubtaskPartLike = Part & {
type: 'subtask';
@@ -248,11 +251,7 @@ const formatTurnDuration = (durationMs: number): string => {
return `${minutes}m ${seconds}s`;
};
const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
const isActivityStandaloneTool = (toolName: unknown): boolean => {
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
};
interface MessageBodyProps {
messageId: string;
@@ -291,6 +290,24 @@ interface MessageBodyProps {
stickyUserHeaderEnabled?: boolean;
}
const TOOL_REVEAL_CACHE_MAX = 200;
const revealedToolIdsByMessage = new Map<string, Set<string>>();
const readRevealedToolIds = (messageId: string): Set<string> => {
const cached = revealedToolIdsByMessage.get(messageId);
return cached ? new Set(cached) : new Set<string>();
};
const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
if (revealedToolIdsByMessage.size >= TOOL_REVEAL_CACHE_MAX && !revealedToolIdsByMessage.has(messageId)) {
const oldest = revealedToolIdsByMessage.keys().next().value;
if (oldest) {
revealedToolIdsByMessage.delete(oldest);
}
}
revealedToolIdsByMessage.set(messageId, new Set(value));
};
const UserMessageBody: React.FC<{
messageId: string;
parts: Part[];
@@ -504,17 +521,17 @@ const UserMessageBody: React.FC<{
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
<FadeInOnReveal key={part.id ?? `user-subtask-${index}`}>
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
<UserSubtaskPart part={part} />
</FadeInOnReveal>
</React.Fragment>
);
}
if (isShellActionPart(part)) {
return (
<FadeInOnReveal key={part.id ?? `user-shell-${index}`}>
<React.Fragment key={part.id ?? `user-shell-${index}`}>
<UserShellActionPart part={part} />
</FadeInOnReveal>
</React.Fragment>
);
}
@@ -527,14 +544,14 @@ const UserMessageBody: React.FC<{
}
}
return (
<FadeInOnReveal key={part.id ?? `user-text-${index}`}>
<React.Fragment key={part.id ?? `user-text-${index}`}>
<UserTextPart
part={part}
messageId={messageId}
isMobile={isMobile}
agentMention={mentionForPart}
/>
</FadeInOnReveal>
</React.Fragment>
);
})}
</div>
@@ -569,17 +586,22 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
turnGroupingContext,
errorMessage,
}) => {
void _streamPhase;
const streamPhase = _streamPhase;
void _allowAnimation;
const [copyHintVisible, setCopyHintVisible] = React.useState(false);
const copyHintTimeoutRef = React.useRef<number | null>(null);
const messageContentRef = React.useRef<HTMLDivElement>(null);
const toolRevealReadyRef = React.useRef(false);
React.useEffect(() => {
toolRevealReadyRef.current = true;
}, []);
const canCopyMessage = Boolean(onCopyMessage);
const isMessageCopied = Boolean(copiedMessage);
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
const awaitingMessageCompletion = !isMessageCompleted;
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
const visibleParts = React.useMemo(() => {
return parts
@@ -594,12 +616,92 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return visibleParts.filter((part): part is ToolPartType => part.type === 'tool');
}, [visibleParts]);
const toolRevealStateRef = React.useRef<{
messageId: string;
hasCommitted: boolean;
persistedToolIds: Set<string>;
animatedToolIds: Set<string>;
}>({
messageId,
hasCommitted: false,
persistedToolIds: readRevealedToolIds(messageId),
animatedToolIds: new Set<string>(),
});
if (toolRevealStateRef.current.messageId !== messageId) {
toolRevealStateRef.current = {
messageId,
hasCommitted: false,
persistedToolIds: readRevealedToolIds(messageId),
animatedToolIds: new Set<string>(),
};
}
const currentToolIds = React.useMemo(() => {
const ids = new Set<string>();
for (const toolPart of toolParts) {
ids.add(toolPart.id);
}
const activitySegments = turnGroupingContext?.activityGroupSegments;
if (Array.isArray(activitySegments)) {
for (const segment of activitySegments) {
if (segment.anchorMessageId !== messageId) {
continue;
}
for (const activity of segment.parts) {
if (activity.kind !== 'tool') {
continue;
}
const toolId = (activity.part as { id?: unknown }).id;
if (typeof toolId === 'string' && toolId.length > 0) {
ids.add(toolId);
}
}
}
}
return Array.from(ids);
}, [messageId, toolParts, turnGroupingContext?.activityGroupSegments]);
const shouldAnimateNewToolMount = Boolean(turnGroupingContext?.isWorking && toolRevealReadyRef.current);
const persistedToolIds = toolRevealStateRef.current.persistedToolIds;
const animatedToolIds = toolRevealStateRef.current.animatedToolIds;
if (shouldAnimateNewToolMount && toolRevealStateRef.current.hasCommitted) {
for (const toolId of currentToolIds) {
if (!persistedToolIds.has(toolId)) {
animatedToolIds.add(toolId);
}
}
}
const animatedToolIdsKey = Array.from(animatedToolIds).join('\u0000');
const animatedToolIdsLookup = React.useMemo(
() => new Set(animatedToolIdsKey ? animatedToolIdsKey.split('\u0000') : []),
[animatedToolIdsKey]
);
React.useEffect(() => {
const nextPersistedToolIds = new Set(toolRevealStateRef.current.persistedToolIds);
for (const toolId of currentToolIds) {
nextPersistedToolIds.add(toolId);
}
toolRevealStateRef.current.persistedToolIds = nextPersistedToolIds;
toolRevealStateRef.current.hasCommitted = true;
writeRevealedToolIds(messageId, nextPersistedToolIds);
}, [currentToolIds, messageId]);
const assistantTextParts = React.useMemo(() => {
return visibleParts.filter((part) => part.type === 'text');
}, [visibleParts]);
const createSessionFromAssistantMessage = useSessionStore((state) => state.createSessionFromAssistantMessage);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const activityRenderMode = useUIStore((state) => state.activityRenderMode);
const isSortedRenderMode = chatRenderMode === 'sorted';
const collapsedPreviewCount = activityRenderMode === 'collapsed' ? 7 : 0;
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const hasStopFinish = messageFinish === 'stop';
@@ -948,330 +1050,263 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
};
}, [clearCopyHintTimeout]);
const toolConnections = React.useMemo(() => {
const connections: Record<string, { hasPrev: boolean; hasNext: boolean }> = {};
const displayableTools = toolParts.filter((toolPart) => {
if (isActivityStandaloneTool(toolPart.tool)) {
return false;
}
return shouldShowTool(toolPart);
});
displayableTools.forEach((toolPart, index) => {
connections[toolPart.id] = {
hasPrev: index > 0,
hasNext: index < displayableTools.length - 1,
};
});
return connections;
}, [toolParts, shouldShowTool]);
const activityPartsForTurn = React.useMemo(() => {
return turnGroupingContext?.activityParts ?? [];
}, [turnGroupingContext]);
const activityPartsForMessage = React.useMemo(() => {
if (!turnGroupingContext) return [];
return activityPartsForTurn.filter((activity) => activity.messageId === messageId);
}, [activityPartsForTurn, messageId, turnGroupingContext]);
const all = turnGroupingContext?.activityParts;
if (!isSortedRenderMode || !all) {
return [];
}
return all;
}, [isSortedRenderMode, turnGroupingContext?.activityParts]);
const activityGroupSegmentsForMessage = React.useMemo(() => {
if (!turnGroupingContext) return [];
return turnGroupingContext.activityGroupSegments.filter((segment) => segment.anchorMessageId === messageId);
}, [messageId, turnGroupingContext]);
const all = turnGroupingContext?.activityGroupSegments;
if (!isSortedRenderMode || !all) {
return [];
}
return all.filter((segment) => segment.anchorMessageId === messageId);
}, [isSortedRenderMode, messageId, turnGroupingContext?.activityGroupSegments]);
const activityPartsByPart = React.useMemo(() => {
const map = new Map<Part, (typeof activityPartsForMessage)[number]>();
activityPartsForMessage.forEach((activity) => {
map.set(activity.part, activity);
});
return map;
}, [activityPartsForMessage]);
const visibleActivityPartsForTurn = React.useMemo(() => {
if (!turnGroupingContext) return [];
// Filter out reasoning if showReasoningTraces is off.
// Justification parts are already filtered at the source (useTurnGrouping)
// based on showTextJustificationActivity, so we keep them here.
const base = !showReasoningTraces
? activityPartsForTurn.filter((activity) => activity.kind !== 'reasoning')
: activityPartsForTurn;
// Tools rendered standalone are excluded from Activity group.
return base.filter((activity) => {
if (activity.kind !== 'tool') {
return true;
const activityByPart = React.useMemo(() => {
const byRef = new Map<Part, (typeof activityPartsForTurn)[number]>();
const byId = new Map<string, (typeof activityPartsForTurn)[number]>();
activityPartsForTurn.forEach((activity) => {
byRef.set(activity.part, activity);
const partId = (activity.part as { id?: unknown }).id;
if (typeof partId === 'string' && partId.length > 0) {
byId.set(partId, activity);
}
const toolName = (activity.part as ToolPartType).tool;
return !isActivityStandaloneTool(toolName);
});
}, [activityPartsForTurn, showReasoningTraces, turnGroupingContext]);
const [hasEverHadMultipleVisibleActivities, setHasEverHadMultipleVisibleActivities] = React.useState(false);
return {
get: (part: Part) => {
const direct = byRef.get(part);
if (direct) {
return direct;
}
const partId = (part as { id?: unknown }).id;
if (typeof partId === 'string' && partId.length > 0) {
return byId.get(partId);
}
return undefined;
},
};
}, [activityPartsForTurn]);
React.useEffect(() => {
if (!turnGroupingContext) {
return;
}
const toggleActivityGroup = turnGroupingContext?.toggleGroup;
const hasTaskSplitSegments = turnGroupingContext.activityGroupSegments.some(
(segment) => segment.afterToolPartId !== null
);
const hasReasoningActivity = visibleActivityPartsForTurn.some(
(activity) => activity.kind === 'reasoning'
);
if (
visibleActivityPartsForTurn.length > 1 ||
(hasTaskSplitSegments && visibleActivityPartsForTurn.length > 0) ||
hasReasoningActivity
) {
setHasEverHadMultipleVisibleActivities(true);
}
}, [turnGroupingContext, visibleActivityPartsForTurn]);
const shouldShowActivityGroup = Boolean(turnGroupingContext && hasEverHadMultipleVisibleActivities);
const shouldRenderActivityGroup = Boolean(
turnGroupingContext &&
shouldShowActivityGroup &&
visibleActivityPartsForTurn.length > 0 &&
activityGroupSegmentsForMessage.length > 0
);
const standaloneToolParts = React.useMemo(() => {
return toolParts.filter((toolPart) => isActivityStandaloneTool(toolPart.tool));
}, [toolParts]);
const shouldRenderActivityGroup = isSortedRenderMode
&& activityGroupSegmentsForMessage.length > 0
&& Boolean(toggleActivityGroup);
const renderedParts = React.useMemo(() => {
const rendered: React.ReactNode[] = [];
const renderActivitySegments = (afterToolPartId: string | null) => {
if (!turnGroupingContext || !shouldRenderActivityGroup) {
return;
}
activityGroupSegmentsForMessage
.filter((segment) => (segment.afterToolPartId ?? null) === afterToolPartId)
.forEach((segment) => {
// Filter out reasoning if showReasoningTraces is off.
// Justification parts are already filtered at the source (useTurnGrouping)
// based on showTextJustificationActivity, so we keep them here.
const visibleSegmentParts = !showReasoningTraces
? segment.parts.filter((activity) => activity.kind !== 'reasoning')
: segment.parts;
if (visibleSegmentParts.length === 0) {
return;
}
rendered.push(
<ProgressiveGroup
key={`progressive-group-${segment.id}`}
parts={visibleSegmentParts}
isExpanded={turnGroupingContext.isGroupExpanded}
onToggle={turnGroupingContext.toggleGroup}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
diffStats={turnGroupingContext.diffStats}
/>
);
});
};
// Activity groups and standalone tasks are interleaved in message order.
// Note: Reasoning parts are rendered in the visibleParts.forEach loop below
// when Activity group isn't showing, to maintain proper ordering with tools.
renderActivitySegments(null);
standaloneToolParts.forEach((standaloneToolPart) => {
rendered.push(
<FadeInOnReveal key={`standalone-tool-${standaloneToolPart.id}`}>
<ToolPart
part={standaloneToolPart}
isExpanded={expandedTools.has(standaloneToolPart.id)}
onToggle={onToggleTool}
if (shouldRenderActivityGroup && toggleActivityGroup) {
activityGroupSegmentsForMessage.forEach((segment) => {
const visibleSegmentParts = showReasoningTraces
? segment.parts
: segment.parts.filter((activity) => activity.kind !== 'reasoning');
if (visibleSegmentParts.length === 0) {
return;
}
rendered.push(
<TurnActivity
key={`progressive-group-${segment.id}`}
parts={visibleSegmentParts}
isExpanded={turnGroupingContext.isGroupExpanded === true}
collapsedPreviewCount={collapsedPreviewCount}
onToggle={toggleActivityGroup}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
hasPrevTool={false}
hasNextTool={false}
onContentChange={onContentChange}
streamPhase={streamPhase}
showHeader={true}
animateRows={animateActivityRows}
animatedToolIds={animatedToolIdsLookup}
diffStats={turnGroupingContext.diffStats}
/>
</FadeInOnReveal>
);
);
});
}
renderActivitySegments(standaloneToolPart.id);
});
// Flat rendering: iterate parts in natural order.
// Group consecutive static tools (read, grep, glob, etc.) into compact rows.
// Expandable tools (bash, edit, task) get individual rows.
// Text and reasoning render inline at their natural position.
let i = 0;
while (i < visibleParts.length) {
const part = visibleParts[i];
const partsWithTime: Array<{
part: Part;
index: number;
endTime: number | null;
element: React.ReactNode;
}> = [];
visibleParts.forEach((part, index) => {
const activity = activityPartsByPart.get(part);
if (!activity) {
return;
if (part.type === 'text') {
const activity = activityByPart.get(part);
if (isSortedRenderMode && !hasStopFinish) {
i += 1;
continue;
}
if (activity?.kind === 'justification') {
i += 1;
continue;
}
rendered.push(
<AssistantTextPart
key={`assistant-text-${messageId}-${i}`}
part={part}
messageId={messageId}
streamPhase={streamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
/>
);
i++;
continue;
}
if (!turnGroupingContext) {
return;
}
let endTime: number | null = null;
let element: React.ReactNode | null = null;
if (!shouldShowActivityGroup) {
if (activity.kind === 'tool') {
const toolPart = part as ToolPartType;
if (isActivityStandaloneTool(toolPart.tool)) {
return;
}
const toolState = (toolPart as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
const time = toolState?.time;
const isFinalized = isToolFinalized(toolPart);
if (!shouldShowTool(toolPart)) {
return;
}
const connection = toolConnections[toolPart.id];
const toolElement = (
<FadeInOnReveal key={`tool-${toolPart.id}`}>
<ToolPart
part={toolPart}
isExpanded={expandedTools.has(toolPart.id)}
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
hasPrevTool={connection?.hasPrev ?? false}
hasNextTool={connection?.hasNext ?? false}
/>
</FadeInOnReveal>
);
element = toolElement;
endTime = isFinalized && typeof time?.end === 'number' ? time.end : null;
} else if (activity.kind === 'reasoning' && showReasoningTraces) {
// Fallback rendering for reasoning when Activity group isn't shown
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const partEndTime = typeof time?.end === 'number' ? time.end : null;
const reasoningElement = (
<FadeInOnReveal key={`reasoning-${activity.id}`}>
if (part.type === 'reasoning') {
const activity = activityByPart.get(part);
if (isSortedRenderMode && !hasStopFinish) {
i += 1;
continue;
}
if (activity?.kind === 'reasoning') {
i += 1;
continue;
}
if (showReasoningTraces) {
if (isSortedRenderMode) {
rendered.push(
<ReasoningPart
key={`reasoning-${messageId}-${i}`}
part={part}
messageId={messageId}
onContentChange={onContentChange}
/>
);
} else {
const partText = (part as { text?: string }).text;
if (partText && partText.trim().length > 0) {
rendered.push(
<FadeInOnReveal key={`reasoning-${messageId}-${i}`}>
<div className="text-sm text-muted-foreground/60 italic leading-relaxed whitespace-pre-wrap">
{partText}
</div>
</FadeInOnReveal>
);
}
}
}
i++;
continue;
}
if (part.type === 'tool') {
const toolPart = part as ToolPartType;
const toolName = toolPart.tool?.toLowerCase() ?? '';
const activity = activityByPart.get(part);
if (activity?.kind === 'tool' && !isStandaloneTool(toolName)) {
i += 1;
continue;
}
if (!shouldShowTool(toolPart)) {
i++;
continue;
}
// Expandable tools: bash, edit, write, task, question — individual rows
if (isExpandableTool(toolName)) {
rendered.push(
<FadeInOnReveal key={`tool-${toolPart.id}`}>
<ToolRevealOnMount animate={animatedToolIdsLookup.has(toolPart.id)} wipe>
<ToolPart
part={toolPart}
isExpanded={expandedTools.has(toolPart.id)}
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/>
</ToolRevealOnMount>
</FadeInOnReveal>
);
element = reasoningElement;
endTime = partEndTime;
i++;
continue;
}
if (element) {
partsWithTime.push({
part,
index,
endTime,
element,
});
// Static tools: group consecutive tools of the same kind into compact rows
const groupToolName = getStaticGroupToolName(toolName);
const group: ToolPartType[] = [toolPart];
let j = i + 1;
while (j < visibleParts.length) {
const next = visibleParts[j];
if (next.type !== 'tool') break;
const nextTool = next as ToolPartType;
if (!shouldShowTool(nextTool)) { j++; continue; }
const nextName = nextTool.tool?.toLowerCase() ?? '';
if (getStaticGroupToolName(nextName) !== groupToolName) break;
group.push(nextTool);
j++;
}
}
});
partsWithTime.sort((a, b) => {
if (a.endTime === null && b.endTime === null) {
return a.index - b.index;
rendered.push(
<FadeInOnReveal key={`static-tools-${toolPart.id}`}>
<ToolRevealOnMount animate={group.some((candidate) => animatedToolIdsLookup.has(candidate.id))} wipe>
<StaticToolRow
toolName={groupToolName}
activities={group.map(tp => ({
id: tp.id,
turnId: '',
messageId: messageId,
partIndex: 0,
part: tp,
kind: 'tool' as const,
}))}
animateTailText={group.some((candidate) => animatedToolIdsLookup.has(candidate.id))}
/>
</ToolRevealOnMount>
</FadeInOnReveal>
);
i = j;
continue;
}
if (a.endTime === null) {
return 1;
}
if (b.endTime === null) {
return -1;
}
return a.endTime - b.endTime;
});
partsWithTime.forEach(({ element }) => {
rendered.push(element);
});
// Unknown part type — skip
i++;
}
return rendered;
}, [
activityPartsByPart,
activityByPart,
activityGroupSegmentsForMessage,
animatedToolIdsLookup,
animateActivityRows,
chatRenderMode,
collapsedPreviewCount,
expandedTools,
hasStopFinish,
isMobile,
isToolFinalized,
isSortedRenderMode,
messageId,
onContentChange,
onShowPopup,
onToggleTool,
shouldRenderActivityGroup,
shouldShowTool,
shouldShowActivityGroup,
streamPhase,
showReasoningTraces,
syntaxTheme,
toolConnections,
toggleActivityGroup,
turnGroupingContext,
visibleParts,
standaloneToolParts,
shouldRenderActivityGroup,
]);
const userMessageId = turnGroupingContext?.turnId;
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const rawSummaryBodyFromStore = useMessageStore((state) => {
if (!userMessageId || !currentSessionId) return undefined;
const sessionMessages = state.messages.get(currentSessionId);
if (!sessionMessages) return undefined;
const userMsg = sessionMessages.find((m) => m.info?.id === userMessageId);
if (!userMsg) return undefined;
const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
const body = summary?.body;
return typeof body === 'string' && body.trim().length > 0 ? body : undefined;
});
const summaryCandidate =
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
? turnGroupingContext.summaryBody
: rawSummaryBodyFromStore;
const summaryBodyRef = React.useRef<string | undefined>(undefined);
if (summaryCandidate && summaryCandidate.trim().length > 0) {
summaryBodyRef.current = summaryCandidate;
}
const prevUserMessageId = React.useRef(userMessageId);
if (prevUserMessageId.current !== userMessageId) {
prevUserMessageId.current = userMessageId;
summaryBodyRef.current = undefined;
}
const summaryBody = summaryBodyRef.current;
const showSummaryBody =
turnGroupingContext?.isLastAssistantInTurn &&
summaryBody &&
summaryBody.trim().length > 0;
// With flat rendering, no collapsed summary is needed — text renders inline.
const showErrorMessage = Boolean(errorMessage);
@@ -1445,42 +1480,9 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</div>
</FadeInOnReveal>
)}
{showSummaryBody && (
<FadeInOnReveal key="summary-body">
<div
className="group/assistant-text relative break-words"
>
<SimpleMarkdownRenderer content={summaryBody} onShowPopup={onShowPopup} />
{shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
<div className="flex items-center gap-1.5">
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : null}
{footerTimestamp ? (
<span
className={footerTimestampClassName}
aria-label={`Message time: ${footerTimestamp}`}
>
<RiTimeLine className="h-3.5 w-3.5" />
{footerTimestamp}
</span>
) : null}
</div>
</div>
)}
</div>
</FadeInOnReveal>
)}
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{!showSummaryBody && shouldShowFooter && (
{shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-start gap-1.5">
<div className="flex items-center gap-1.5">
{footerButtons}
@@ -2,7 +2,6 @@ import React from 'react';
import { RiAiAgentLine, RiBrainAi3Line, RiUser3Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { getAgentColor } from '@/lib/agentColors';
import { FadeInOnReveal } from './FadeInOnReveal';
import { useProviderLogo } from '@/hooks/useProviderLogo';
interface MessageHeaderProps {
@@ -18,84 +17,82 @@ const MessageHeader: React.FC<MessageHeaderProps> = ({ isUser, providerID, agent
const { src: logoSrc, onError: handleLogoError, hasLogo } = useProviderLogo(providerID);
return (
<FadeInOnReveal>
<div className={cn('mb-2')}>
<div className={cn('flex items-center justify-between gap-2')}>
<div className={cn('mb-2')}>
<div className={cn('flex items-center justify-between gap-2')}>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{isUser ? (
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<RiUser3Line className="h-4 w-4 text-primary" />
</div>
) : (
<div className="flex items-center justify-center">
{hasLogo && logoSrc ? (
<img
src={logoSrc}
alt={`${providerID} logo`}
className="h-4 w-4"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleLogoError}
/>
) : (
<RiBrainAi3Line
className="h-4 w-4"
style={{ color: `var(${getAgentColor(agentName).var})` }}
/>
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex-shrink-0">
{isUser ? (
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
<RiUser3Line className="h-4 w-4 text-primary" />
</div>
) : (
<div className="flex items-center justify-center">
{hasLogo && logoSrc ? (
<img
src={logoSrc}
alt={`${providerID} logo`}
className="h-4 w-4"
style={{
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
}}
onError={handleLogoError}
/>
) : (
<RiBrainAi3Line
className="h-4 w-4"
style={{ color: `var(${getAgentColor(agentName).var})` }}
/>
)}
</div>
<h3
className={cn(
'font-bold typography-ui-header tracking-tight leading-none',
isUser ? 'text-primary' : 'text-foreground'
)}
</div>
<div className="flex items-center gap-2">
<h3
>
{isUser ? 'You' : (modelName || 'Assistant')}
</h3>
{!isUser && agentName && (
<div
className={cn(
'font-bold typography-ui-header tracking-tight leading-none',
isUser ? 'text-primary' : 'text-foreground'
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
getAgentColor(agentName).class
)}
>
{isUser ? 'You' : (modelName || 'Assistant')}
</h3>
{!isUser && agentName && (
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
getAgentColor(agentName).class
)}
>
<RiAiAgentLine className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{agentName}</span>
</div>
)}
{!isUser && variant && (
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
variant === 'Default' ? undefined : 'agent-info'
)}
style={
variant === 'Default'
? ({
'--agent-color': 'var(--muted-foreground)',
'--agent-color-bg': 'var(--muted-foreground)',
} as React.CSSProperties)
: undefined
}
>
<RiBrainAi3Line className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
</div>
)}
</div>
<RiAiAgentLine className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{agentName}</span>
</div>
)}
{!isUser && variant && (
<div
className={cn(
'flex items-center gap-1 px-1.5 py-0 rounded cursor-default',
'agent-badge typography-meta',
'hover:bg-[rgb(from_var(--agent-color-bg)_r_g_b_/_0.1)] hover:border-[rgb(from_var(--agent-color)_r_g_b_/_0.2)]',
variant === 'Default' ? undefined : 'agent-info'
)}
style={
variant === 'Default'
? ({
'--agent-color': 'var(--muted-foreground)',
'--agent-color-bg': 'var(--muted-foreground)',
} as React.CSSProperties)
: undefined
}
>
<RiBrainAi3Line className="h-3 w-3 flex-shrink-0" />
<span className="font-medium">{variant.length > 0 ? variant[0].toLowerCase() + variant.slice(1) : variant}</span>
</div>
)}
</div>
</div>
</div>
</FadeInOnReveal>
</div>
);
};
@@ -0,0 +1,33 @@
import type { Part } from '@opencode-ai/sdk/v2';
const shouldKeepSyntheticUserText = (text: string): boolean => {
const trimmed = text.trim();
if (trimmed.startsWith('User has requested to enter plan mode')) return true;
if (trimmed.startsWith('The plan at ')) return true;
if (trimmed.startsWith('The following tool was executed by the user')) return true;
return false;
};
export const normalizeUserDisplayParts = (parts: Part[]): Part[] => {
return parts
.filter((part) => {
const synthetic = (part as { synthetic?: boolean }).synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;
const text = (part as { text?: unknown }).text;
return typeof text === 'string' ? shouldKeepSyntheticUserText(text) : false;
})
.map((part) => {
const rawPart = part as Record<string, unknown>;
if (rawPart.type === 'compaction') {
return { type: 'text', text: '/compact' } as Part;
}
if (rawPart.type === 'text') {
const text = typeof rawPart.text === 'string' ? rawPart.text.trim() : '';
if (text.startsWith('The following tool was executed by the user')) {
return { type: 'text', text: '/shell' } as Part;
}
}
return part;
});
};
@@ -3,7 +3,8 @@ import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ReasoningTimelineBlock, formatReasoningText } from './ReasoningPart';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
type PartWithText = Part & { text?: string; content?: string; value?: string; time?: { start?: number; end?: number } };
@@ -11,69 +12,91 @@ interface AssistantTextPartProps {
part: Part;
messageId: string;
streamPhase: StreamPhase;
allowAnimation: boolean;
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
renderAsReasoning?: boolean;
}
const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
part,
messageId,
streamPhase,
allowAnimation,
onContentChange,
renderAsReasoning = false,
chatRenderMode = 'live',
}) => {
const partWithText = part as PartWithText;
const rawText = partWithText.text;
const baseTextContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
const textContent = React.useMemo(() => {
if (renderAsReasoning) {
return formatReasoningText(baseTextContent);
}
return baseTextContent;
}, [baseTextContent, renderAsReasoning]);
const rawText = typeof partWithText.text === 'string' ? partWithText.text : '';
const contentText = typeof partWithText.content === 'string' ? partWithText.content : '';
const valueText = typeof partWithText.value === 'string' ? partWithText.value : '';
const textContent = [rawText, contentText, valueText].reduce((best, candidate) => {
return candidate.length > best.length ? candidate : best;
}, '');
const isStreamingPhase = streamPhase === 'streaming';
const isCooldownPhase = streamPhase === 'cooldown';
const wasStreamingRef = React.useRef(isStreamingPhase);
const isStreaming = chatRenderMode === 'live' && (isStreamingPhase || isCooldownPhase);
if (isStreamingPhase || isCooldownPhase) {
wasStreamingRef.current = true;
return null;
}
const throttledTextContent = useStreamingTextThrottle({
text: textContent,
isStreaming,
identityKey: `${messageId}:${part.id ?? 'text'}`,
});
const displayTextContent = resolveAssistantDisplayText({
textContent,
throttledTextContent,
isStreaming,
});
const lastDisplayLengthRef = React.useRef(0);
React.useEffect(() => {
if (!isStreaming || typeof window === 'undefined') {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
const debugEnabled = window.localStorage.getItem('openchamber_stream_debug') === '1';
if (!debugEnabled) {
lastDisplayLengthRef.current = displayTextContent.length;
return;
}
if (displayTextContent.length < lastDisplayLengthRef.current) {
console.info('[STREAM-TRACE] render_shrink', {
messageId,
partId: part.id,
rawTextLen: rawText.length,
contentLen: contentText.length,
valueLen: valueText.length,
chosenLen: textContent.length,
throttledLen: throttledTextContent.length,
displayLen: displayTextContent.length,
prevDisplayLen: lastDisplayLengthRef.current,
});
}
lastDisplayLengthRef.current = displayTextContent.length;
}, [contentText.length, displayTextContent.length, isStreaming, messageId, part.id, rawText.length, textContent.length, throttledTextContent.length, valueText.length]);
const time = partWithText.time;
const isFinalized = time && typeof time.end !== 'undefined';
const isFinalized = Boolean(time && typeof time.end !== 'undefined');
if (!isFinalized && (!textContent || textContent.trim().length === 0)) {
const isRenderableTextPart = part.type === 'text' || part.type === 'reasoning';
if (!isRenderableTextPart) {
return null;
}
if (!textContent || textContent.trim().length === 0) {
if (!shouldRenderAssistantText({
displayTextContent,
isFinalized,
})) {
return null;
}
if (renderAsReasoning) {
return (
<ReasoningTimelineBlock
key={part.id || `${messageId}-text`}
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning-text`}
time={time}
/>
);
}
return (
<div className="group/assistant-text relative break-words" key={part.id || `${messageId}-text`}>
<MarkdownRenderer
content={textContent}
content={displayTextContent}
part={part}
messageId={messageId}
isAnimated={allowAnimation}
isStreaming={false}
isAnimated={false}
isStreaming={isStreaming}
disableStreamAnimation={chatRenderMode === 'sorted'}
variant={part.type === 'reasoning' ? 'reasoning' : 'assistant'}
/>
</div>
);
@@ -0,0 +1,87 @@
# Chat Message Parts: Rendering Architecture
This folder contains renderers for chat message parts (text, tools, reasoning, placeholders) and shared tool presentation helpers.
Use this doc when you ask an agent to change tool/header/description behavior.
## High-level flow
- Message parts are rendered from `MessageBody.tsx`.
- There are two tool rendering paths:
- **Static grouped tools** -> `StaticToolRow` in `ProgressiveGroup.tsx`
- **Expandable tools** -> `ToolPart.tsx`
- Shared tool icon mapping is centralized in `toolPresentation.tsx` (`getToolIcon`).
## Which file controls what
- `ProgressiveGroup.tsx`
- Renders grouped Activity rows and grouped static tools.
- Contains `StaticToolRow`.
- Contains static tool short description logic (`getToolShortDescription`).
- If you want to change how `read/grep/perplexity/webfetch/...` look in compact/grouped mode, edit here.
- `ToolPart.tsx`
- Renders expandable tool rows (bash/edit/write/question/task + fallback).
- Controls expandable header title/description/diff stats/timer and expanded output body.
- If you want to change expandable tool layout, edit here.
- `toolPresentation.tsx`
- Shared icon mapping for tool names (`getToolIcon`).
- Used by both `ProgressiveGroup.tsx` and `ToolPart.tsx`.
- `toolRenderUtils.ts`
- Core classification helpers:
- `isExpandableTool`
- `isStaticTool`
- `isStandaloneTool`
- `getStaticGroupToolName`
- If a tool should switch between static vs expandable, change it here.
- `ReasoningPart.tsx`
- Thinking block UI (`ReasoningTimelineBlock`), summary + optional duration.
- `JustificationBlock.tsx`
- Justification block wrapper over `ReasoningTimelineBlock`.
## Current important behavior
- `read` and most search/fetch tools are treated as **static tools** and usually render via `StaticToolRow`.
- `bash/edit/write/question/task` are **expandable tools** and render via `ToolPart`.
- `perplexity` is currently treated as static and grouped into search/web-search style rows (through static grouping + short description extraction).
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
## "I want to change description for Perplexity" (example recipe)
If task is: "change text shown near Perplexity tool header/description":
1. Edit `ProgressiveGroup.tsx` -> `getToolShortDescription(activity)`.
2. Update the branch that handles web-search tools (`websearch`, `web-search`, `search_web`, `codesearch`, `perplexity`, etc.).
3. If needed, update group rendering in `StaticToolRow` (search/fetch specific rendering branches).
4. Keep icon changes (if any) in `toolPresentation.tsx`.
Why: in current pipeline Perplexity is static/grouped, so `StaticToolRow` is the primary path.
## "I want tool to become expandable" (example)
1. Update `toolRenderUtils.ts`:
- add/remove tool name in `EXPANDABLE_TOOL_NAMES`
2. Ensure `ToolPart.tsx` supports desired header + expanded output format for that tool.
3. Validate both modes (`sorted` and `live`).
## Safe editing checklist
- Do not duplicate icon logic; keep it in `toolPresentation.tsx`.
- For static tool copy changes, prefer `ProgressiveGroup.tsx` first.
- For expanded output changes, edit `ToolPart.tsx`.
- After edits run:
- `bun run type-check`
- `bun run lint`
- `bun run build`
## Quick map of files in this folder
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
- Status/placeholders: `WorkingPlaceholder.tsx`, `GenericStatusSpinner.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
@@ -0,0 +1,56 @@
import React from 'react';
/**
* Starfield Twinkle — a 4×4 grid of tiny dots that flicker
* like stars in a night sky. Each dot has its own random phase
* and duration so the pattern never looks mechanical.
*
* Corners are hidden (same as original) to soften the grid shape.
*/
const COLS = 4;
const ROWS = 4;
const SPACING = 3.2; // viewBox units between centers
const OFFSET = 2.7; // center the grid in 15×15
const DOT_R = 0.7; // small dot radius — star-like
const cornerIndices = new Set([0, 3, 12, 15]);
const stars = Array.from({ length: COLS * ROWS }, (_, i) => ({
id: i,
cx: (i % COLS) * SPACING + OFFSET,
cy: Math.floor(i / COLS) * SPACING + OFFSET,
isCorner: cornerIndices.has(i),
// Each star gets its own rhythm — varying duration + delay
duration: 2.4 + Math.random() * 2.4,
delay: Math.random() * 3.5,
}));
export function GenericStatusSpinner({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 15 15"
data-component="opencode-spinner"
className={className}
fill="var(--foreground)"
aria-hidden="true"
>
{stars.map((star) => (
<circle
key={star.id}
cx={star.cx}
cy={star.cy}
r={DOT_R}
style={
star.isCorner
? { opacity: 0 }
: {
animation: `star-twinkle ${star.duration}s ease-in-out infinite`,
animationDelay: `${star.delay}s`,
}
}
/>
))}
</svg>
);
}
@@ -1,6 +1,7 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart';
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
@@ -29,6 +30,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
messageId,
onContentChange,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText;
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
@@ -46,6 +48,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
/>
);
};
@@ -0,0 +1,109 @@
import React from 'react';
import { Text } from '@/components/ui/text';
const MAX_SHINE_DURATION_MS = 5 * 60 * 1000; // 5 minutes cap
interface MinDurationShineTextProps {
active: boolean;
minDurationMs?: number;
className?: string;
children: React.ReactNode;
style?: React.CSSProperties;
title?: string;
}
export const MinDurationShineText: React.FC<MinDurationShineTextProps> = ({
active,
minDurationMs = 300,
className,
children,
style,
title,
}) => {
// Once active, we latch shine on and only turn it off after active becomes
// false AND minDurationMs has elapsed since we first started shining.
// All bookkeeping lives in refs so intermediate re-renders (children
// changing, props updating) can never cause a flicker.
const shineStartRef = React.useRef<number | null>(active ? Date.now() : null);
const [isShining, setIsShining] = React.useState(active);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Latch on: if active becomes true, start shining immediately.
if (active && shineStartRef.current === null) {
shineStartRef.current = Date.now();
}
if (active && !isShining) {
// Synchronous state set during render is fine for a latch-on — React
// will coalesce it with the current render pass.
// But we can't call setState during render, so we use an effect below.
}
React.useEffect(() => {
if (active) {
// Cancel any pending off-timer.
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (shineStartRef.current === null) {
shineStartRef.current = Date.now();
}
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
const elapsed = Date.now() - shineStartRef.current;
if (elapsed >= MAX_SHINE_DURATION_MS) {
setIsShining(false);
shineStartRef.current = null;
return;
}
setIsShining(true);
return;
}
if (!isShining) {
shineStartRef.current = null;
return;
}
// active went false — schedule turn-off respecting minDurationMs.
const startedAt = shineStartRef.current ?? Date.now();
const elapsed = Date.now() - startedAt;
// Cap shine duration at 5 minutes max to prevent infinite shine on stuck tools
if (elapsed >= MAX_SHINE_DURATION_MS) {
setIsShining(false);
shineStartRef.current = null;
return;
}
const remaining = Math.max(0, minDurationMs - elapsed);
timerRef.current = setTimeout(() => {
setIsShining(false);
shineStartRef.current = null;
timerRef.current = null;
}, remaining);
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [active, minDurationMs, isShining]);
if (isShining) {
return (
<Text variant="shine" className={className} title={title}>
{children}
</Text>
);
}
return (
<span className={className} style={style} title={title}>
{children}
</span>
);
};
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,6 @@ import type { ComponentType } from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { formatTimestampForDisplay } from '../timeFormat';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useUIStore } from '@/stores/useUIStore';
@@ -89,6 +88,7 @@ type ReasoningTimelineBlockProps = {
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string;
time?: { start?: number; end?: number };
showDuration?: boolean;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
@@ -97,23 +97,14 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
onContentChange,
blockId,
time,
showDuration = true,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const isMobile = useUIStore((state) => state.isMobile);
const showActivityHeaderTimestamps = useUIStore((state) => state.showActivityHeaderTimestamps);
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const { label, Icon } = variantConfig[variant];
const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined;
const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined;
const endedTimestampText = React.useMemo(() => {
if (typeof timeEnd !== 'number') {
return null;
}
const formatted = formatTimestampForDisplay(timeEnd);
return formatted.length > 0 ? formatted : null;
}, [timeEnd]);
React.useEffect(() => {
if (text.trim().length === 0) {
@@ -158,38 +149,18 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
<span className="typography-meta font-medium">{label}</span>
</div>
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
{(summary || (showDuration && typeof timeStart === 'number')) ? (
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
{summary ? <span className="flex-1 min-w-0 truncate italic">{summary}</span> : null}
{typeof timeStart === 'number' ? (
{showDuration && typeof timeStart === 'number' ? (
<span className="relative flex-shrink-0 tabular-nums text-right">
<span
className={cn(
'text-muted-foreground/80 transition-opacity duration-150',
!isMobile && endedTimestampText && showActivityHeaderTimestamps && 'group-hover/tool:opacity-0'
)}
>
<span className="text-muted-foreground/80 transition-opacity duration-150">
<LiveDuration
start={timeStart}
end={timeEnd}
active={typeof timeEnd !== 'number'}
/>
</span>
{!isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
<span
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 whitespace-nowrap rounded-sm bg-[var(--surface-background)] px-1 text-muted-foreground/70 transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</span>
) : null}
{typeof timeStart !== 'number' && !isMobile && endedTimestampText && showActivityHeaderTimestamps ? (
<span className="text-muted-foreground/70 flex-shrink-0 tabular-nums">
{endedTimestampText}
</span>
) : null}
</div>
@@ -199,9 +170,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
{isExpanded && (
<div
className={cn(
'relative pr-2 pb-2 pt-2 pl-[1.4375rem]',
'before:absolute before:left-[0.4375rem] before:w-px before:bg-border/80 before:content-[""]',
'before:top-[-0.25rem] before:bottom-0'
'relative pr-2 pb-2 pt-2 pl-4'
)}
>
<ScrollableOverlay
@@ -228,6 +197,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
onContentChange,
messageId,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText;
const rawText = partWithText.text || partWithText.content || '';
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
@@ -246,6 +216,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
/>
);
};
@@ -0,0 +1,270 @@
import React from 'react';
/**
* 5x5 grid letter patterns (indices 0-24).
* Grid layout:
* 0 1 2 3 4
* 5 6 7 8 9
* 10 11 12 13 14
* 15 16 17 18 19
* 20 21 22 23 24
*
* Each letter is represented as an array of "on" cell indices.
*/
const LETTER_PATTERNS: Record<string, readonly number[]> = {
// 0 1 2 3 4
// 5 6 7 8 9
// 10 11 12 13 14
// 15 16 17 18 19
// 20 21 22 23 24
A: [1, 2, 3, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
B: [0, 1, 2, 3, 5, 9, 10, 11, 12, 13, 15, 19, 20, 21, 22, 23],
C: [1, 2, 3, 5, 10, 15, 21, 22, 23],
D: [0, 1, 2, 3, 5, 9, 10, 14, 15, 19, 20, 21, 22, 23],
E: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20, 21, 22, 23],
F: [0, 1, 2, 3, 5, 10, 11, 12, 15, 20],
G: [1, 2, 3, 5, 10, 12, 13, 15, 18, 19, 21, 22, 23],
H: [0, 4, 5, 9, 10, 11, 12, 13, 14, 15, 19, 20, 24],
I: [1, 2, 3, 7, 12, 17, 21, 22, 23],
J: [1, 2, 3, 8, 13, 15, 18, 21, 22],
K: [0, 3, 5, 7, 10, 11, 15, 17, 20, 23],
L: [0, 5, 10, 15, 20, 21, 22, 23],
M: [0, 4, 5, 6, 8, 9, 10, 12, 14, 15, 19, 20, 24],
N: [0, 4, 5, 6, 9, 10, 12, 14, 15, 18, 19, 20, 24],
O: [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
P: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 20],
Q: [1, 2, 3, 5, 9, 10, 14, 15, 18, 19, 21, 22, 24],
R: [0, 1, 2, 3, 5, 8, 9, 10, 11, 12, 13, 15, 17, 20, 23],
S: [1, 2, 3, 5, 11, 12, 13, 19, 21, 22, 23],
T: [0, 1, 2, 3, 4, 7, 12, 17, 22],
U: [0, 4, 5, 9, 10, 14, 15, 19, 21, 22, 23],
V: [0, 4, 5, 9, 10, 14, 16, 18, 22],
W: [0, 4, 5, 9, 10, 12, 14, 15, 16, 18, 19, 21, 23],
X: [0, 4, 6, 8, 12, 16, 18, 20, 24],
Y: [0, 4, 6, 8, 12, 17, 22],
Z: [0, 1, 2, 3, 4, 8, 12, 16, 20, 21, 22, 23, 24],
'0': [1, 2, 3, 5, 9, 10, 14, 15, 19, 21, 22, 23],
'1': [2, 6, 7, 12, 17, 20, 21, 22, 23, 24],
'2': [1, 2, 3, 9, 11, 12, 13, 16, 20, 21, 22, 23, 24],
'3': [0, 1, 2, 3, 9, 11, 12, 13, 19, 20, 21, 22, 23],
'4': [0, 4, 5, 9, 10, 11, 12, 13, 14, 19, 24],
'5': [0, 1, 2, 3, 4, 5, 10, 11, 12, 13, 19, 20, 21, 22, 23],
'6': [1, 2, 3, 5, 10, 11, 12, 13, 15, 19, 21, 22, 23],
'7': [0, 1, 2, 3, 4, 9, 13, 17, 22],
'8': [1, 2, 3, 5, 9, 11, 12, 13, 15, 19, 21, 22, 23],
'9': [1, 2, 3, 5, 9, 11, 12, 13, 19, 21, 22, 23],
' ': [],
};
// Build Set versions for O(1) lookups
const LETTER_SETS: Record<string, Set<number>> = {};
for (const [key, indices] of Object.entries(LETTER_PATTERNS)) {
LETTER_SETS[key] = new Set(indices);
}
/** Duration each letter is displayed (ms) */
const LETTER_DURATION_MS = 800;
/** Crossfade transition duration (ms) */
const TRANSITION_MS = 500;
/** Pause between full cycles (ms) */
const CYCLE_PAUSE_MS = 1000;
/** Spacing between dot centers in SVG units */
const DOT_SPACING = 4;
/** Dot radius */
const DOT_RADIUS = 1.2;
/**
* Octagonal grid layout (7 rows):
*
* • • • row 0: 3 dots (cols 2-4)
* • • • • • row 1: 5 dots (cols 1-5) → letter row 0
* • • • • • • • row 2: 7 dots (cols 0-6) → letter row 1
* • • • • • • • row 3: 7 dots (cols 0-6) → letter row 2
* • • • • • • • row 4: 7 dots (cols 0-6) → letter row 3
* • • • • • row 5: 5 dots (cols 1-5) → letter row 4
* • • • row 6: 3 dots (cols 2-4)
*
* Letter indices (0-24) map to the inner 5x5 zone:
* rows 1-5, cols 1-5
*/
const OCTAGON_ROWS: { row: number; cols: number[] }[] = [
{ row: 0, cols: [2, 3, 4] },
{ row: 1, cols: [1, 2, 3, 4, 5] },
{ row: 2, cols: [0, 1, 2, 3, 4, 5, 6] },
{ row: 3, cols: [0, 1, 2, 3, 4, 5, 6] },
{ row: 4, cols: [0, 1, 2, 3, 4, 5, 6] },
{ row: 5, cols: [1, 2, 3, 4, 5] },
{ row: 6, cols: [2, 3, 4] },
];
interface OctCell {
id: number;
cx: number;
cy: number;
/** Index into the 5x5 letter grid (0-24), or -1 for border-only dots */
letterIndex: number;
// Stable random timing
shimmerDuration: number;
shimmerDelay: number;
idleDuration: number;
idleDelay: number;
}
const CELLS: OctCell[] = [];
let cellId = 0;
for (const { row, cols } of OCTAGON_ROWS) {
for (const col of cols) {
const cx = col * DOT_SPACING;
const cy = row * DOT_SPACING;
// Letter zone: rows 1-5 (octagon), cols 1-5 (octagon)
// maps to 5x5 letter index
let letterIndex = -1;
const letterRow = row - 1;
const letterCol = col - 1;
if (letterRow >= 0 && letterRow < 5 && letterCol >= 0 && letterCol < 5) {
letterIndex = letterRow * 5 + letterCol;
}
CELLS.push({
id: cellId++,
cx,
cy,
letterIndex,
shimmerDuration: 3 + Math.random() * 3,
shimmerDelay: Math.random() * 3,
idleDuration: 1 + Math.random(),
idleDelay: Math.random() * 1.5,
});
}
}
const VIEW_SIZE = 6 * DOT_SPACING + DOT_RADIUS * 2;
const VIEW_OFFSET = -DOT_RADIUS;
interface SessionActiveSpinnerProps {
className?: string;
/** Text to spell out letter by letter. Falls back to idle pulse when empty/undefined. */
text?: string;
}
/**
* Idle mode: random pulsing octagonal dot grid.
* Text mode: cycles through characters of `text`, morphing between letter shapes.
*/
export function SessionActiveSpinner({ className, text }: SessionActiveSpinnerProps) {
const normalizedText = text?.toUpperCase().replace(/[^A-Z0-9 ]/g, '') || '';
const hasText = normalizedText.length > 0;
const [charIndex, setCharIndex] = React.useState(0);
const [phase, setPhase] = React.useState<'hold' | 'morph'>('hold');
// Intro fade: foreground starts invisible and fades in
const [introReady, setIntroReady] = React.useState(false);
React.useEffect(() => {
const id = requestAnimationFrame(() => setIntroReady(true));
return () => cancelAnimationFrame(id);
}, []);
// Reset on text change
React.useEffect(() => {
setCharIndex(0);
setPhase('hold');
}, [normalizedText]);
// Letter cycling timer
React.useEffect(() => {
if (!hasText) return;
const total = normalizedText.length;
if (phase === 'hold') {
const isLastChar = charIndex === total - 1;
const delay = LETTER_DURATION_MS + (isLastChar ? CYCLE_PAUSE_MS : 0);
const timer = setTimeout(() => setPhase('morph'), delay);
return () => clearTimeout(timer);
}
const timer = setTimeout(() => {
setCharIndex((prev) => (prev + 1) % total);
setPhase('hold');
}, TRANSITION_MS);
return () => clearTimeout(timer);
}, [hasText, charIndex, normalizedText, phase]);
// Compute current and next letter sets for morphing
const total = normalizedText.length;
const currentSet = hasText
? (LETTER_SETS[normalizedText[charIndex]] ?? LETTER_SETS[' '])
: null;
const nextIndex = hasText ? (charIndex + 1) % total : 0;
const nextSet = hasText
? (LETTER_SETS[normalizedText[nextIndex]] ?? LETTER_SETS[' '])
: null;
return (
<svg
viewBox={`${VIEW_OFFSET} ${VIEW_OFFSET} ${VIEW_SIZE} ${VIEW_SIZE}`}
data-component="session-active-spinner"
className={className}
fill="var(--foreground)"
aria-hidden="true"
>
{/* Background layer: all dots with shimmer animation */}
{CELLS.map((cell) => (
<circle
key={cell.id}
cx={cell.cx}
cy={cell.cy}
r={DOT_RADIUS}
style={{
animation: `${currentSet ? 'pulse-opacity-dim' : 'pulse-opacity'} ${currentSet ? cell.shimmerDuration : cell.idleDuration}s ease-in-out infinite`,
animationDelay: `${currentSet ? cell.shimmerDelay : cell.idleDelay}s`,
animationFillMode: 'both',
}}
/>
))}
{/* Foreground layer: morphing letter dots (only on letter-zone cells) */}
<g fill="var(--primary)">
{currentSet && nextSet && CELLS.map((cell) => {
if (cell.letterIndex < 0) return null;
const inCurrent = currentSet.has(cell.letterIndex);
const inNext = nextSet.has(cell.letterIndex);
if (!inCurrent && !inNext) return null;
let opacity: number;
if (!introReady) {
opacity = 0;
} else if (phase === 'hold') {
opacity = inCurrent ? 1 : 0;
} else {
if (inCurrent && inNext) {
opacity = 1;
} else if (inCurrent) {
opacity = 0;
} else {
opacity = 1;
}
}
return (
<circle
key={`fg-${cell.id}`}
cx={cell.cx}
cy={cell.cy}
r={DOT_RADIUS}
style={{
opacity,
transition: `opacity ${TRANSITION_MS}ms ease-in-out`,
}}
/>
);
})}
</g>
</svg>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
import React from 'react';
const WIPE_MASK =
'linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)';
interface ToolRevealOnMountProps {
children: React.ReactNode;
animate: boolean;
wipe?: boolean;
delayMs?: number;
className?: string;
}
export const ToolRevealOnMount: React.FC<ToolRevealOnMountProps> = ({
children,
animate,
wipe = true,
delayMs = 0,
className,
}) => {
const rootRef = React.useRef<HTMLDivElement | null>(null);
const clearRevealStyles = React.useCallback((target: HTMLElement | null) => {
if (!target) {
return;
}
target.style.opacity = '';
target.style.filter = '';
target.style.transform = '';
target.style.maskImage = '';
target.style.webkitMaskImage = '';
target.style.maskSize = '';
target.style.webkitMaskSize = '';
target.style.maskRepeat = '';
target.style.webkitMaskRepeat = '';
target.style.maskPosition = '';
target.style.webkitMaskPosition = '';
}, []);
React.useLayoutEffect(() => {
const el = rootRef.current;
if (!animate) {
clearRevealStyles(el);
return;
}
if (!el || typeof window === 'undefined') {
return;
}
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
clearRevealStyles(el);
return;
}
const maskSupported =
wipe &&
typeof CSS !== 'undefined' &&
(CSS.supports('mask-image', 'linear-gradient(to right, black, transparent)') ||
CSS.supports('-webkit-mask-image', 'linear-gradient(to right, black, transparent)'));
el.style.opacity = '0';
el.style.filter = wipe ? 'blur(3px)' : 'blur(2px)';
el.style.transform = wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)';
if (maskSupported) {
el.style.maskImage = WIPE_MASK;
el.style.webkitMaskImage = WIPE_MASK;
el.style.maskSize = '240% 100%';
el.style.webkitMaskSize = '240% 100%';
el.style.maskRepeat = 'no-repeat';
el.style.webkitMaskRepeat = 'no-repeat';
el.style.maskPosition = '100% 0%';
el.style.webkitMaskPosition = '100% 0%';
}
let animation: Animation | null = null;
const frame = window.requestAnimationFrame(() => {
const node = rootRef.current;
if (!node) {
return;
}
const keyframes: Keyframe[] = maskSupported
? [
{ opacity: 0, filter: 'blur(3px)', transform: 'translateX(-0.06em)', maskPosition: '100% 0%' },
{ opacity: 1, filter: 'blur(0px)', transform: 'translateX(0)', maskPosition: '0% 0%' },
]
: [
{
opacity: 0,
filter: wipe ? 'blur(3px)' : 'blur(2px)',
transform: wipe ? 'translateX(-0.06em)' : 'translateY(0.04em)',
},
{ opacity: 1, filter: 'blur(0px)', transform: wipe ? 'translateX(0)' : 'translateY(0)' },
];
animation = node.animate(keyframes, {
duration: 500,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
delay: delayMs,
fill: 'forwards',
});
animation.finished
.catch(() => undefined)
.finally(() => {
const target = rootRef.current;
clearRevealStyles(target);
});
});
return () => {
window.cancelAnimationFrame(frame);
animation?.cancel();
clearRevealStyles(el);
};
}, [animate, clearRevealStyles, delayMs, wipe]);
return (
<div ref={rootRef} className={className}>
{children}
</div>
);
};
@@ -1,5 +1,7 @@
import React from 'react';
import { Text } from '@/components/ui/text';
// import { SessionActiveSpinner } from './SessionActiveSpinner';
import { GenericStatusSpinner } from './GenericStatusSpinner';
interface WorkingPlaceholderProps {
isWorking: boolean;
@@ -7,6 +9,7 @@ interface WorkingPlaceholderProps {
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
agentName?: string;
}
const STATUS_DISPLAY_TIME_MS = 1200;
@@ -191,12 +194,13 @@ export function WorkingPlaceholder({
return (
<div
className="flex h-full items-center text-muted-foreground pl-[2ch]"
className="flex h-full items-center text-muted-foreground pl-0.5"
role="status"
aria-live="polite"
aria-label={retryText}
>
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-1">
<GenericStatusSpinner className="size-[15px] shrink-0" />
<Text variant="shine" className="typography-ui-header">
{retryText}
</Text>
@@ -215,14 +219,15 @@ export function WorkingPlaceholder({
return (
<div
className={
'flex h-full items-center text-muted-foreground pl-[2ch]'
'flex h-full items-center text-muted-foreground pl-0.5'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
<span className="flex items-center gap-1">
<GenericStatusSpinner className="size-[15px] shrink-0" />
<Text variant="shine" className="typography-ui-header">
{displayText}
</Text>
@@ -0,0 +1,17 @@
export const resolveAssistantDisplayText = (input: {
textContent: string;
throttledTextContent: string;
isStreaming: boolean;
}): string => {
return input.isStreaming ? input.throttledTextContent : input.textContent;
};
export const shouldRenderAssistantText = (input: {
displayTextContent: string;
isFinalized: boolean;
}): boolean => {
if (!input.isFinalized && input.displayTextContent.trim().length === 0) {
return false;
}
return input.displayTextContent.trim().length > 0;
};
@@ -0,0 +1,87 @@
import React from 'react';
import {
RiAiAgentLine,
RiBookLine,
RiFileEditLine,
RiFileList2Line,
RiFileSearchLine,
RiFileTextLine,
RiFolder6Line,
RiGitBranchLine,
RiGlobalLine,
RiListCheck2,
RiListCheck3,
RiMenuSearchLine,
RiPencilLine,
RiSurveyLine,
RiTaskLine,
RiTerminalBoxLine,
RiToolsLine,
} from '@remixicon/react';
export const getToolIcon = (toolName: string) => {
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
const tool = toolName.toLowerCase();
if (tool === 'edit' || tool === 'multiedit' || tool === 'apply_patch' || tool === 'str_replace' || tool === 'str_replace_based_edit_tool') {
return <RiPencilLine className={iconClass} />;
}
if (tool === 'write' || tool === 'create' || tool === 'file_write') {
return <RiFileEditLine className={iconClass} />;
}
if (tool === 'read' || tool === 'view' || tool === 'file_read' || tool === 'cat') {
return <RiFileTextLine className={iconClass} />;
}
if (tool === 'bash' || tool === 'shell' || tool === 'cmd' || tool === 'terminal') {
return <RiTerminalBoxLine className={iconClass} />;
}
if (tool === 'list' || tool === 'ls' || tool === 'dir' || tool === 'list_files') {
return <RiFolder6Line className={iconClass} />;
}
if (tool === 'search' || tool === 'grep' || tool === 'find' || tool === 'ripgrep') {
return <RiMenuSearchLine className={iconClass} />;
}
if (tool === 'glob') {
return <RiFileSearchLine className={iconClass} />;
}
if (tool === 'fetch' || tool === 'curl' || tool === 'wget' || tool === 'webfetch') {
return <RiGlobalLine className={iconClass} />;
}
if (
tool === 'web-search' ||
tool === 'websearch' ||
tool === 'search_web' ||
tool === 'codesearch' ||
tool === 'google' ||
tool === 'bing' ||
tool === 'duckduckgo' ||
tool === 'perplexity'
) {
return <RiGlobalLine className={iconClass} />;
}
if (tool === 'todowrite' || tool === 'todoread') {
return <RiListCheck3 className={iconClass} />;
}
if (tool === 'structuredoutput' || tool === 'structured_output') {
return <RiListCheck2 className={iconClass} />;
}
if (tool === 'skill') {
return <RiBookLine className={iconClass} />;
}
if (tool === 'task') {
return <RiAiAgentLine className={iconClass} />;
}
if (tool === 'question') {
return <RiSurveyLine className={iconClass} />;
}
if (tool === 'plan_enter') {
return <RiFileList2Line className={iconClass} />;
}
if (tool === 'plan_exit') {
return <RiTaskLine className={iconClass} />;
}
if (tool.startsWith('git')) {
return <RiGitBranchLine className={iconClass} />;
}
return <RiToolsLine className={iconClass} />;
};
@@ -0,0 +1,44 @@
const EXPANDABLE_TOOL_NAMES = new Set<string>([
'edit', 'multiedit', 'apply_patch', 'str_replace', 'str_replace_based_edit_tool',
'bash', 'shell', 'cmd', 'terminal',
'write', 'create', 'file_write',
'question', 'task',
]);
const STANDALONE_TOOL_NAMES = new Set<string>(['task']);
const SEARCH_TOOL_NAMES = new Set<string>(['grep', 'search', 'find', 'ripgrep', 'glob']);
const normalizeToolName = (toolName: unknown): string => {
if (typeof toolName !== 'string') return '';
const trimmed = toolName.trim().toLowerCase();
if (!trimmed) return '';
const withoutIndex = trimmed.replace(/:\d+$/, '');
if (withoutIndex.includes('.')) {
const parts = withoutIndex.split('.').filter(Boolean);
return parts[parts.length - 1] ?? withoutIndex;
}
return withoutIndex;
};
export const isExpandableTool = (toolName: unknown): boolean => {
return EXPANDABLE_TOOL_NAMES.has(normalizeToolName(toolName));
};
export const isStandaloneTool = (toolName: unknown): boolean => {
return STANDALONE_TOOL_NAMES.has(normalizeToolName(toolName));
};
export const isStaticTool = (toolName: unknown): boolean => {
if (typeof toolName !== 'string') return false;
return !isExpandableTool(toolName) && !isStandaloneTool(toolName);
};
export const getStaticGroupToolName = (toolName: string): string => {
const normalized = normalizeToolName(toolName);
if (SEARCH_TOOL_NAMES.has(normalized)) {
return 'grep';
}
return normalized;
};