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
+146 -306
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
import { RiArrowLeftLine } from '@remixicon/react';
import { useShallow } from 'zustand/react/shallow';
import type { Message, Part } from '@opencode-ai/sdk/v2';
@@ -9,87 +9,83 @@ import { useUIStore } from '@/stores/useUIStore';
import { Skeleton } from '@/components/ui/skeleton';
import ChatEmptyState from './ChatEmptyState';
import MessageList, { type MessageListHandle } from './MessageList';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
import { useDeviceInfo } from '@/lib/device';
import { getMemoryLimits } from '@/stores/types/sessionTypes';
import { Button } from '@/components/ui/button';
import { ButtonSmall } from '@/components/ui/button-small';
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
import { TimelineDialog } from './TimelineDialog';
import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import { cn } from '@/lib/utils';
import {
collectVisibleSessionIdsForBlockingRequests,
flattenBlockingRequests,
} from './lib/blockingRequests';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
const EMPTY_QUESTIONS: QuestionRequest[] = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const collectVisibleSessionIdsForBlockingRequests = (
sessions: Array<{ id: string; parentID?: string }> | undefined,
currentSessionId: string | null
): string[] => {
if (!currentSessionId) return [];
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
const current = sessions.find((session) => session.id === currentSessionId);
if (!current) return [currentSessionId];
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
if (current.parentID) {
return [];
}
const childIds = sessions
.filter((session) => session.parentID === currentSessionId)
.map((session) => session.id);
return [currentSessionId, ...childIds];
type HydratingToolSkeletonRow = {
id: string;
titleWidth: string;
detailWidth: string;
};
const flattenBlockingRequests = <T extends { id: string }>(
source: Map<string, T[]>,
sessionIds: string[]
): T[] => {
if (sessionIds.length === 0) return [];
const seen = new Set<string>();
const result: T[] = [];
for (const sessionId of sessionIds) {
const entries = source.get(sessionId);
if (!entries || entries.length === 0) continue;
for (const entry of entries) {
if (seen.has(entry.id)) continue;
seen.add(entry.id);
result.push(entry);
}
}
return result;
};
const HYDRATING_SKELETON_ITEMS: Array<{
id: number;
toolRows: HydratingToolSkeletonRow[];
textWidths: [string, string, string];
}> = [
{
id: 1,
toolRows: [
{ id: 'search', titleWidth: 'w-24', detailWidth: 'w-52' },
{ id: 'read', titleWidth: 'w-20', detailWidth: 'w-36' },
{ id: 'edit', titleWidth: 'w-24', detailWidth: 'w-64' },
],
textWidths: ['w-24', 'w-[92%]', 'w-[78%]'],
},
{
id: 2,
toolRows: [
{ id: 'read', titleWidth: 'w-20', detailWidth: 'w-40' },
{ id: 'search', titleWidth: 'w-24', detailWidth: 'w-48' },
],
textWidths: ['w-20', 'w-[88%]', 'w-[70%]'],
},
{
id: 3,
toolRows: [
{ id: 'shell', titleWidth: 'w-28', detailWidth: 'w-44' },
{ id: 'edit', titleWidth: 'w-24', detailWidth: 'w-56' },
],
textWidths: ['w-24', 'w-[84%]', 'w-[64%]'],
},
];
export const ChatContainer: React.FC = () => {
const {
currentSessionId,
isLoading,
loadMessages,
loadMoreMessages,
updateViewportAnchor,
openNewSessionDraft,
setCurrentSession,
trimToViewportWindow,
newSessionDraft,
} = useSessionStore(
useShallow((state) => ({
currentSessionId: state.currentSessionId,
isLoading: state.isLoading,
loadMessages: state.loadMessages,
loadMoreMessages: state.loadMoreMessages,
updateViewportAnchor: state.updateViewportAnchor,
openNewSessionDraft: state.openNewSessionDraft,
setCurrentSession: state.setCurrentSession,
trimToViewportWindow: state.trimToViewportWindow,
newSessionDraft: state.newSessionDraft,
}))
);
@@ -107,6 +103,7 @@ export const ChatContainer: React.FC = () => {
setTimelineDialogOpen,
isExpandedInput,
stickyUserHeader,
chatRenderMode,
} = useUIStore();
const sessionMessages = useSessionStore(
@@ -144,9 +141,9 @@ export const ChatContainer: React.FC = () => {
return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
}, [blockingRequestState.questions, scopedSessionIds]);
const memoryState = useSessionStore(
const historyMeta = useSessionStore(
React.useCallback(
(state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
(state) => (currentSessionId ? state.sessionHistoryMeta.get(currentSessionId) ?? null : null),
[currentSessionId]
)
);
@@ -216,115 +213,60 @@ export const ChatContainer: React.FC = () => {
}
}, [currentSessionId, draftOpen, openNewSessionDraft]);
const [turnStart, setTurnStart] = React.useState(0);
const turnHandleRef = React.useRef<number | null>(null);
const turnIdleRef = React.useRef(false);
const initializedTurnStartSessionRef = React.useRef<string | null>(null);
const TURN_INIT = 5;
const TURN_BATCH = 8;
const userTurnIndexes = React.useMemo(() => {
const indexes: number[] = [];
for (let i = 0; i < sessionMessages.length; i += 1) {
const message = sessionMessages[i];
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
if (role === 'user') {
indexes.push(i);
}
}
return indexes;
}, [sessionMessages]);
const cancelTurnBackfill = React.useCallback(() => {
const handle = turnHandleRef.current;
if (handle === null) {
return;
}
turnHandleRef.current = null;
if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') {
window.cancelIdleCallback(handle);
return;
}
if (typeof window !== 'undefined') {
window.clearTimeout(handle);
}
}, []);
const renderedSessionMessages = React.useMemo(() => {
if (turnStart <= 0 || userTurnIndexes.length === 0) {
return sessionMessages;
}
const startIndex = userTurnIndexes[turnStart] ?? 0;
return sessionMessages.slice(startIndex);
}, [sessionMessages, turnStart, userTurnIndexes]);
const backfillTurns = React.useCallback(() => {
if (turnStart <= 0) {
return;
}
const container = typeof document !== 'undefined'
? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null)
: null;
const beforeTop = container?.scrollTop ?? null;
const beforeHeight = container?.scrollHeight ?? null;
setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0));
if (container && beforeTop !== null && beforeHeight !== null) {
window.requestAnimationFrame(() => {
const delta = container.scrollHeight - beforeHeight;
if (delta !== 0) {
container.scrollTop = beforeTop + delta;
}
});
}
}, [turnStart]);
const scheduleTurnBackfill = React.useCallback(() => {
if (turnHandleRef.current !== null || turnStart <= 0) {
return;
}
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
turnIdleRef.current = true;
turnHandleRef.current = window.requestIdleCallback(() => {
turnHandleRef.current = null;
backfillTurns();
});
return;
}
turnIdleRef.current = false;
turnHandleRef.current = window.setTimeout(() => {
turnHandleRef.current = null;
backfillTurns();
}, 0);
}, [backfillTurns, turnStart]);
const sessionBlockingCards = React.useMemo(() => {
return [...sessionPermissions, ...sessionQuestions];
}, [sessionPermissions, sessionQuestions]);
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
const {
scrollRef,
handleMessageContentChange,
getAnimationHandlers,
showScrollButton,
scrollToBottom,
scrollToPosition,
releasePinnedScroll,
isPinned,
isOverflowing,
isProgrammaticFollowActive,
} = useChatScrollManager({
currentSessionId,
sessionMessages: renderedSessionMessages,
sessionMessages,
streamingMessageId,
sessionMemoryState: sessionMemoryStateMap,
updateViewportAnchor,
isSyncing,
isMobile,
chatRenderMode,
messageStreamStates,
sessionPermissions: sessionBlockingCards,
trimToViewportWindow,
onActiveTurnChange: (turnId) => {
activeTurnChangeRef.current(turnId);
},
});
const timelineController = useChatTimelineController({
sessionId: currentSessionId,
messages: sessionMessages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
scrollToBottom,
isPinned,
isOverflowing,
});
React.useEffect(() => {
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
}, [timelineController.handleActiveTurnChange]);
const navigation = useChatTurnNavigation({
sessionId: currentSessionId,
turnIds: timelineController.turnIds,
activeTurnId: timelineController.activeTurnId,
scrollToTurn: timelineController.scrollToTurn,
scrollToMessage: timelineController.scrollToMessage,
resumeToBottom: timelineController.resumeToBottom,
});
React.useLayoutEffect(() => {
@@ -354,131 +296,13 @@ export const ChatContainer: React.FC = () => {
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
React.useEffect(() => {
cancelTurnBackfill();
if (!currentSessionId) {
initializedTurnStartSessionRef.current = null;
setTurnStart(0);
return;
}
if (initializedTurnStartSessionRef.current === currentSessionId) {
return;
}
if (sessionMessages.length === 0) {
setTurnStart(0);
return;
}
const turnCount = userTurnIndexes.length;
const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0;
setTurnStart(start);
initializedTurnStartSessionRef.current = currentSessionId;
}, [cancelTurnBackfill, currentSessionId, sessionMessages.length, userTurnIndexes.length]);
const isSessionActive = sessionStatusForCurrent.type === 'busy' || sessionStatusForCurrent.type === 'retry';
React.useEffect(() => {
if (isSessionActive) {
cancelTurnBackfill();
return;
}
scheduleTurnBackfill();
return () => {
cancelTurnBackfill();
};
}, [cancelTurnBackfill, isSessionActive, scheduleTurnBackfill, turnStart]);
const hasMoreAbove = React.useMemo(() => {
if (!memoryState) {
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
}
if (memoryState.historyComplete === true) {
return false;
}
if (memoryState.hasMoreAbove) {
return true;
}
if (memoryState.historyComplete === false) {
return true;
}
// Backward compatibility: older persisted sessions may miss history flags.
if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) {
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
}
return false;
}, [memoryState, sessionMessages.length]);
const hasHistoryMetadata = React.useMemo(() => {
if (!memoryState) {
return false;
}
return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined;
}, [memoryState]);
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
React.useEffect(() => {
setIsLoadingOlder(false);
}, [currentSessionId]);
return Boolean(historyMeta);
}, [historyMeta]);
const handleLoadOlder = React.useCallback(async () => {
if (!currentSessionId || isLoadingOlder) {
return;
}
cancelTurnBackfill();
setTurnStart(0);
const container = scrollRef.current;
const anchor = messageListRef.current?.captureViewportAnchor() ?? null;
const prevHeight = container?.scrollHeight ?? null;
const prevTop = container?.scrollTop ?? null;
setIsLoadingOlder(true);
void loadMoreMessages(currentSessionId, 'up')
.then(() => {
const restored = anchor ? (messageListRef.current?.restoreViewportAnchor(anchor) ?? false) : false;
if (!restored && container && prevHeight !== null && prevTop !== null) {
const heightDiff = container.scrollHeight - prevHeight;
scrollToPosition(prevTop + heightDiff, { instant: true });
}
})
.finally(() => {
setIsLoadingOlder(false);
});
}, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
const handleRenderEarlier = React.useCallback(() => {
cancelTurnBackfill();
setTurnStart(0);
}, [cancelTurnBackfill]);
// Scroll to a specific message by ID (for timeline dialog)
const scrollToMessage = React.useCallback((messageId: string) => {
if (messageListRef.current?.scrollToMessageId(messageId, { behavior: 'smooth' })) {
return;
}
const container = scrollRef.current;
if (!container) return;
// Find the message element by looking for data-message-id attribute
const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement;
if (messageElement) {
// Scroll to the message with some padding (50px from top)
const containerRect = container.getBoundingClientRect();
const messageRect = messageElement.getBoundingClientRect();
const offset = 50;
const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset;
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
});
}
}, [scrollRef]);
const isSessionHydrating =
Boolean(currentSessionId)
&& (!hasSessionMessagesEntry || !hasHistoryMetadata || historyMeta?.loading === true);
React.useEffect(() => {
if (!currentSessionId) {
@@ -494,7 +318,8 @@ export const ChatContainer: React.FC = () => {
await loadMessages(currentSessionId).finally(() => {
const statusType = sessionStatusForCurrent.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
const shouldSkipScroll = isActivePhase && isPinned;
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
const shouldSkipScroll = (isActivePhase && isPinned) || hasHashTarget;
if (!shouldSkipScroll) {
if (typeof window === 'undefined') {
@@ -551,32 +376,44 @@ export const ChatContainer: React.FC = () => {
return null;
}
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = hasSessionMessagesEntry;
if (!hasMessagesEntry) {
return (
<div
className="relative flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-message-column space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 p-4">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-20 w-full" />
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto bg-background pt-6">
<div className="space-y-4">
{HYDRATING_SKELETON_ITEMS.map((item) => (
<div key={item.id} className="group w-full">
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
<ChatInput scrollToBottom={scrollToBottom} />
</div>
);
}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
);
}
if (sessionMessages.length === 0 && !streamingMessageId) {
@@ -632,22 +469,25 @@ export const ChatContainer: React.FC = () => {
<div className="relative z-0 min-h-full">
<MessageList
ref={messageListRef}
messages={renderedSessionMessages}
sessionKey={currentSessionId}
turnStart={timelineController.turnStart}
disableStaging={timelineController.pendingRevealWork}
messages={timelineController.renderedMessages}
permissions={sessionPermissions}
questions={sessionQuestions}
onMessageContentChange={handleMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
hasMoreAbove={hasMoreAbove}
isLoadingOlder={isLoadingOlder}
onLoadOlder={handleLoadOlder}
hasRenderEarlier={turnStart > 0}
onRenderEarlier={handleRenderEarlier}
hasMoreAbove={timelineController.historySignals.hasMoreAboveTurns}
isLoadingOlder={timelineController.isLoadingOlder}
onLoadOlder={() => {
void timelineController.loadEarlier();
}}
scrollToBottom={scrollToBottom}
scrollRef={scrollRef}
/>
</div>
</ScrollShadow>
<OverlayScrollbar containerRef={scrollRef} />
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly />
</div>
</div>
@@ -659,19 +499,11 @@ export const ChatContainer: React.FC = () => {
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
)}
>
{!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
<Button
variant="outline"
size="sm"
onClick={() => scrollToBottom({ force: true })}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom"
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
</div>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
)}
<ChatInput scrollToBottom={scrollToBottom} />
</div>
@@ -679,7 +511,15 @@ export const ChatContainer: React.FC = () => {
<TimelineDialog
open={isTimelineDialogOpen}
onOpenChange={setTimelineDialogOpen}
onScrollToMessage={scrollToMessage}
onScrollToMessage={(messageId) => {
releasePinnedScroll();
return navigation.scrollToMessageId(messageId, { behavior: 'smooth', updateHash: false });
}}
onScrollByTurnOffset={(offset) => {
releasePinnedScroll();
void navigation.scrollByTurnOffset(offset);
}}
onResumeToLatest={navigation.resumeToLatest}
/>
</div>
);
+14 -15
View File
@@ -1224,8 +1224,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
clearAbortPrompt();
startAbortIndicator();
void abortCurrentOperation();
}, [abortCurrentOperation, clearAbortPrompt, startAbortIndicator]);
void abortCurrentOperation(currentSessionId || undefined);
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
const handleCycleAgent = React.useCallback(() => {
if (primaryAgents.length <= 1) return;
@@ -2381,19 +2381,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
data-keyboard-avoid="true"
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
{/* Absolute positioned above input - no layout shift */}
<div className="absolute bottom-full left-0 right-0">
<StatusRow
isWorking={working.isWorking}
statusText={workingStatusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
showAbortStatus={showAbortStatus}
/>
</div>
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList />
<QueuedMessageChips
@@ -2521,6 +2508,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</button>
</div>
)}
<StatusRow
isWorking={working.isWorking}
statusText={workingStatusText}
isGenericStatus={working.isGenericStatus}
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
abortActive={working.abortActive}
retryInfo={working.retryInfo}
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos
/>
<div
className={cn(
"flex flex-col relative overflow-visible",
+246 -230
View File
@@ -5,7 +5,6 @@ import { useShallow } from 'zustand/react/shallow';
import { defaultCodeDark, defaultCodeLight } from '@/lib/codeTheme';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { useSessionStore } from '@/stores/useSessionStore';
import { useMessageStore } from '@/stores/messageStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useContextStore } from '@/stores/contextStore';
@@ -21,22 +20,42 @@ import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
import { deriveMessageRole } from './message/messageRole';
import { filterVisibleParts } from './message/partUtils';
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { TurnGroupingContext } from './hooks/useTurnGrouping';
import type { TurnGroupingContext } from './lib/turns/types';
import { copyTextToClipboard } from '@/lib/clipboard';
import { FadeInOnReveal } from './message/FadeInOnReveal';
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
const TOOL_DEFAULT_EXPANSION_BY_MODE = {
detailed: new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']),
changes: new Set(['edit', 'multiedit', 'write', 'apply_patch']),
} as const;
type DefaultExpandedToolMode = keyof typeof TOOL_DEFAULT_EXPANSION_BY_MODE;
const EXPANDED_TOOLS_CACHE_MAX = 4000;
const expandedToolsStateCache = new Map<string, Set<string>>();
const collapsedToolsStateCache = new Map<string, Set<string>>();
const BASH_TOOL_NAMES = new Set(['bash', 'shell', 'cmd', 'terminal']);
const EDIT_TOOL_NAMES = new Set([
'apply_patch',
'edit',
'write',
'multiedit',
'str_replace',
'str_replace_based_edit_tool',
'create',
'file_write',
]);
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('.')) {
return withoutIndex;
}
const parts = withoutIndex.split('.').filter(Boolean);
return parts[parts.length - 1] ?? withoutIndex;
};
const readExpandedToolsCache = (messageId: string): Set<string> => {
const cached = expandedToolsStateCache.get(messageId);
@@ -53,8 +72,20 @@ const writeExpandedToolsCache = (messageId: string, value: Set<string>): void =>
expandedToolsStateCache.set(messageId, new Set(value));
};
const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean =>
typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase());
const readCollapsedToolsCache = (messageId: string): Set<string> => {
const cached = collapsedToolsStateCache.get(messageId);
return cached ? new Set(cached) : new Set();
};
const writeCollapsedToolsCache = (messageId: string, value: Set<string>): void => {
if (collapsedToolsStateCache.size >= EXPANDED_TOOLS_CACHE_MAX && !collapsedToolsStateCache.has(messageId)) {
const oldest = collapsedToolsStateCache.keys().next().value;
if (typeof oldest === 'string') {
collapsedToolsStateCache.delete(oldest);
}
}
collapsedToolsStateCache.set(messageId, new Set(value));
};
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
@@ -92,6 +123,8 @@ interface ChatMessageProps {
animationHandlers?: AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
turnGroupingContext?: TurnGroupingContext;
animateUserOnMount?: boolean;
onUserAnimationConsumed?: (messageId: string) => void;
}
const ChatMessage: React.FC<ChatMessageProps> = ({
@@ -101,6 +134,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onContentChange,
animationHandlers,
turnGroupingContext,
animateUserOnMount = false,
onUserAnimationConsumed,
}) => {
const { isMobile, hasTouchInput } = useDeviceInfo();
const { currentTheme } = useThemeSystem();
@@ -136,11 +171,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
} = sessionState;
const providers = useConfigStore((state) => state.providers);
const { showReasoningTraces, toolCallExpansion, stickyUserHeader } = useUIStore(
const { showReasoningTraces, stickyUserHeader, chatRenderMode, showExpandedBashTools, showExpandedEditTools } = useUIStore(
useShallow((state) => ({
showReasoningTraces: state.showReasoningTraces,
toolCallExpansion: state.toolCallExpansion,
stickyUserHeader: state.stickyUserHeader,
chatRenderMode: state.chatRenderMode,
showExpandedBashTools: state.showExpandedBashTools,
showExpandedEditTools: state.showExpandedEditTools,
}))
);
@@ -153,6 +190,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
const [copiedMessage, setCopiedMessage] = React.useState(false);
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
const [collapsedTools, setCollapsedTools] = React.useState<Set<string>>(() => readCollapsedToolsCache(message.info.id));
const [popupContent, setPopupContent] = React.useState<ToolPopupContent>({
open: false,
title: '',
@@ -161,12 +199,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
React.useEffect(() => {
setExpandedTools(readExpandedToolsCache(message.info.id));
setCollapsedTools(readCollapsedToolsCache(message.info.id));
}, [message.info.id]);
React.useEffect(() => {
expandedToolsStateCache.clear();
setExpandedTools(new Set());
}, [toolCallExpansion]);
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
@@ -188,35 +224,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return message.parts;
}
const keepSyntheticUserText = (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;
};
return message.parts
.filter((part) => {
const synthetic = (part as unknown as { synthetic?: boolean })?.synthetic === true;
if (!synthetic) return true;
if (part.type !== 'text') return false;
const text = (part as unknown as { text?: unknown })?.text;
return typeof text === 'string' ? keepSyntheticUserText(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;
});
return normalizeUserDisplayParts(message.parts);
}, [isUser, message.parts]);
const previousUserMetadata = React.useMemo(() => {
@@ -425,8 +433,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return visibleParts;
}
return isMessageCompleted ? visibleParts : [];
}, [isUser, isMessageCompleted, visibleParts]);
if (!isMessageCompleted && chatRenderMode === 'sorted') {
return [];
}
return visibleParts;
}, [chatRenderMode, isMessageCompleted, isUser, visibleParts]);
const assistantTextParts = React.useMemo(() => {
@@ -444,65 +456,57 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return filtered;
}, [isUser, visibleParts]);
const turnActivityToolParts = React.useMemo(() => {
if (isUser) {
return [] as Part[];
}
const records = turnGroupingContext?.activityParts ?? [];
return records
.filter((record) => record.kind === 'tool')
.map((record) => record.part)
.filter((part): part is Part => part.type === 'tool');
}, [isUser, turnGroupingContext?.activityParts]);
const defaultOpenToolIds = React.useMemo(() => {
if (!showExpandedBashTools && !showExpandedEditTools) {
return new Set<string>();
}
const next = new Set<string>();
for (const part of [...toolParts, ...turnActivityToolParts]) {
const toolId = typeof part?.id === 'string' ? part.id : '';
if (!toolId) continue;
const toolName = normalizeToolName((part as { tool?: string }).tool);
if (!toolName) continue;
if (showExpandedBashTools && BASH_TOOL_NAMES.has(toolName)) {
next.add(toolId);
continue;
}
if (showExpandedEditTools && EDIT_TOOL_NAMES.has(toolName)) {
next.add(toolId);
}
}
return next;
}, [showExpandedBashTools, showExpandedEditTools, toolParts, turnActivityToolParts]);
const effectiveExpandedTools = React.useMemo(() => {
// 'collapsed': Activity and tools start collapsed
// 'activity': Activity expanded, tools collapsed
// 'detailed': Activity expanded, only key tools expanded
// 'changes': Activity expanded, only edit/diff tools expanded
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
if (defaultOpenToolIds.size === 0 && collapsedTools.size === 0) {
return expandedTools;
}
const defaultExpansionMode =
toolCallExpansion === 'detailed' || toolCallExpansion === 'changes'
? toolCallExpansion
: null;
if (!defaultExpansionMode) {
return expandedTools;
}
// 'detailed'/'changes': expand only allowlisted tools by default.
// expandedTools acts as a "toggled" set (XOR with defaults).
const defaultExpandedToolIds = new Set<string>();
for (const part of toolParts) {
const toolName = (part as { tool?: unknown }).tool;
if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) {
defaultExpandedToolIds.add(part.id);
const next = new Set(expandedTools);
defaultOpenToolIds.forEach((toolId) => {
if (!collapsedTools.has(toolId)) {
next.add(toolId);
}
}
if (turnGroupingContext?.isFirstAssistantInTurn) {
for (const activity of turnGroupingContext.activityParts) {
if (activity.kind !== 'tool') {
continue;
}
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) {
if (toolPart.id) {
defaultExpandedToolIds.add(toolPart.id);
}
if (activity.id) {
defaultExpandedToolIds.add(activity.id);
}
}
}
}
const effective = new Set(defaultExpandedToolIds);
for (const id of expandedTools) {
if (effective.has(id)) {
effective.delete(id);
} else {
effective.add(id);
}
}
return effective;
}, [expandedTools, toolCallExpansion, toolParts, turnGroupingContext]);
});
collapsedTools.forEach((toolId) => {
next.delete(toolId);
});
return next;
}, [collapsedTools, defaultOpenToolIds, expandedTools]);
const agentMention = React.useMemo(() => {
if (!isUser) {
@@ -564,21 +568,22 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
const previousRole = React.useMemo(() => {
if (!previousMessage) return null;
return deriveMessageRole(previousMessage.info);
}, [previousMessage]);
const nextRole = React.useMemo(() => {
if (!nextMessage) return null;
return deriveMessageRole(nextMessage.info);
}, [nextMessage]);
const hasTurnGrouping = Boolean(turnGroupingContext);
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
const isFollowedByAssistant = React.useMemo(() => {
if (isUser) return false;
if (hasTurnGrouping) {
return !isLastAssistantInTurn;
}
if (!nextRole) return false;
return !nextRole.isUser && nextRole.role === 'assistant';
}, [isUser, nextRole]);
}, [hasTurnGrouping, isLastAssistantInTurn, isUser, nextRole]);
const streamPhase: StreamPhase = React.useMemo(() => {
if (isMessageCompleted) {
@@ -590,6 +595,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return isStreamingMessage ? 'streaming' : 'completed';
}, [isMessageCompleted, lifecyclePhase, isStreamingMessage]);
React.useEffect(() => {
if (!isUser || !animateUserOnMount) {
return;
}
onUserAnimationConsumed?.(message.info.id);
}, [animateUserOnMount, isUser, message.info.id, onUserAnimationConsumed]);
React.useEffect(() => {
setHasStartedStreamingHeader(false);
}, [message.info.id]);
@@ -630,10 +642,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return false;
}
// Fallback to original logic when turn grouping is not available
if (!previousRole) return true;
return previousRole.isUser;
}, [hasStartedStreamingHeader, isUser, previousRole, turnGroupingContext, streamPhase, message.info]);
// Ungrouped fallback path: always show assistant header.
return true;
}, [hasStartedStreamingHeader, isUser, turnGroupingContext, streamPhase, message.info.id]);
const handleCopyCode = React.useCallback((code: string) => {
void copyTextToClipboard(code).then((result) => {
@@ -645,50 +656,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, []);
const userMessageIdForTurn = turnGroupingContext?.turnId;
const { assistantSummaryFromStore, variantFromTurnStore } = useMessageStore(
useShallow((state) => {
if (!userMessageIdForTurn || !message.info.sessionID) {
return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
}
const sessionMessages = state.messages.get(message.info.sessionID);
if (!sessionMessages) {
return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
}
const userMsg = sessionMessages.find((entry) => entry.info?.id === userMessageIdForTurn);
if (!userMsg) {
return { assistantSummaryFromStore: undefined, variantFromTurnStore: undefined };
}
const summary = (userMsg.info as { summary?: { body?: string | null | undefined } | null | undefined }).summary;
const body = summary?.body;
const variant = (userMsg.info as { variant?: unknown }).variant;
return {
assistantSummaryFromStore: typeof body === 'string' && body.trim().length > 0 ? body : undefined,
variantFromTurnStore: typeof variant === 'string' && variant.trim().length > 0 ? variant : undefined,
};
})
);
const headerVariantRaw = !isUser ? (variantFromTurnStore ?? previousUserMetadata?.variant) : undefined;
const headerVariantRaw = !isUser ? (turnGroupingContext?.userMessageVariant ?? previousUserMetadata?.variant) : undefined;
const headerVariant = !isUser && modelHasVariants ? (headerVariantRaw ?? 'Default') : undefined;
const assistantSummaryCandidate =
typeof turnGroupingContext?.summaryBody === 'string' && turnGroupingContext.summaryBody.trim().length > 0
? turnGroupingContext.summaryBody
: assistantSummaryFromStore;
const [assistantSummaryForCopy, setAssistantSummaryForCopy] = React.useState<string | undefined>(undefined);
React.useEffect(() => {
setAssistantSummaryForCopy(undefined);
}, [userMessageIdForTurn]);
React.useEffect(() => {
if (assistantSummaryCandidate && assistantSummaryCandidate.trim().length > 0) {
setAssistantSummaryForCopy(assistantSummaryCandidate);
}
}, [assistantSummaryCandidate]);
// Summary body removed — flat rendering means text is always inline.
const assistantErrorText = React.useMemo(() => {
if (isUser) {
@@ -758,12 +730,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return assistantErrorText;
}
if (assistantSummaryForCopy && assistantSummaryForCopy.trim().length > 0) {
return assistantSummaryForCopy;
}
return flattenAssistantTextParts(displayParts);
}, [assistantErrorText, assistantSummaryForCopy, displayParts, isUser]);
}, [assistantErrorText, displayParts, isUser]);
const hasTextContent = messageTextContent.length > 0;
@@ -788,6 +756,32 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [sessionId, message.info.id, forkFromMessage]);
const handleToggleTool = React.useCallback((toolId: string) => {
const isDefaultOpen = defaultOpenToolIds.has(toolId);
const isCurrentlyExpanded = effectiveExpandedTools.has(toolId);
if (isDefaultOpen) {
setCollapsedTools((prev) => {
const next = new Set(prev);
if (isCurrentlyExpanded) {
next.add(toolId);
} else {
next.delete(toolId);
}
writeCollapsedToolsCache(message.info.id, next);
return next;
});
if (!isCurrentlyExpanded) {
setExpandedTools((prev) => {
const next = new Set(prev);
next.delete(toolId);
writeExpandedToolsCache(message.info.id, next);
return next;
});
}
return;
}
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(toolId)) {
@@ -798,7 +792,17 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
writeExpandedToolsCache(message.info.id, next);
return next;
});
}, [message.info.id]);
setCollapsedTools((prev) => {
if (!prev.has(toolId)) {
return prev;
}
const next = new Set(prev);
next.delete(toolId);
writeCollapsedToolsCache(message.info.id, next);
return next;
});
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
@@ -807,6 +811,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => {
animationCompletedRef.current = false;
@@ -814,6 +819,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false;
}, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
@@ -843,7 +849,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
}, [setImagePreviewOpen]);
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
const isStreamingPhase = streamPhase === 'streaming';
const isStreamingPhase = streamPhase === 'streaming' || streamPhase === 'cooldown';
if (isStreamingPhase) {
hasEverStreamedRef.current = true;
}
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
@@ -852,7 +862,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase;
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
@@ -970,83 +980,89 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
)}
id={`message-${message.info.id}`}
data-message-id={message.info.id}
ref={messageContainerRef}
>
<div className="chat-message-column relative">
{isUser ? (
displayParts.length === 0 ? null : (
<FadeInOnReveal>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className="max-w-[85%]">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader}
/>
<FadeInOnReveal
forceAnimation
skipAnimation={!animateUserOnMount}
ignoreContextDisabled
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className="max-w-[85%]">
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
stickyUserHeaderEnabled={stickyUserHeader}
/>
</div>
{useExternalUserActionsRow ? (
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader}
/>
) : null}
</div>
{useExternalUserActionsRow ? (
<MessageBody
messageId={message.info.id}
parts={displayParts}
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
onToggleTool={handleToggleTool}
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
errorMessage={assistantErrorText}
userActionsMode="external-actions"
stickyUserHeaderEnabled={stickyUserHeader}
/>
) : null}
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
</div>
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
</div>
</FadeInOnReveal>
</FadeInOnReveal>
)
) : (
<div className="relative">
@@ -671,15 +671,17 @@ const stripLeadingFrontmatter = (markdown: string): string => {
return markdown.slice(frontmatterMatch[0].length);
};
export type MarkdownVariant = 'assistant' | 'tool';
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
interface MarkdownRendererProps {
content: string;
part?: Part;
messageId: string;
isAnimated?: boolean;
skipFadeIn?: boolean;
className?: string;
isStreaming?: boolean;
disableStreamAnimation?: boolean;
variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void;
}
@@ -1325,8 +1327,10 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
part,
messageId,
isAnimated = true,
skipFadeIn = false,
className,
isStreaming = false,
disableStreamAnimation = false,
variant = 'assistant',
onShowPopup,
}) => {
@@ -1350,18 +1354,27 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
: 'streamdown-content';
: variant === 'reasoning'
? 'streamdown-content streamdown-reasoning'
: 'streamdown-content';
const streamdownAnimated = React.useMemo(
() => ({ animation: 'blurIn' as const, duration: 150, easing: 'ease-out' }),
[],
);
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
<Streamdown
<Streamdown
key={`streamdown-${componentKey}-${currentMermaidTheme.metadata.id}:${currentMermaidTheme.metadata.variant}`}
mode={isStreaming ? 'streaming' : 'static'}
mode={isStreaming && !disableStreamAnimation ? 'streaming' : 'static'}
shikiTheme={shikiThemes}
className={streamdownClassName}
controls={streamdownControls}
plugins={streamdownPlugins}
components={streamdownComponents}
animated={disableStreamAnimation ? undefined : streamdownAnimated}
isAnimating={disableStreamAnimation ? false : isStreaming}
>
{content}
</Streamdown>
@@ -1370,7 +1383,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
if (isAnimated) {
return (
<FadeInOnReveal key={componentKey}>
<FadeInOnReveal key={componentKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
@@ -1425,7 +1438,9 @@ export const SimpleMarkdownRenderer: React.FC<{
const streamdownClassName = variant === 'tool'
? 'streamdown-content streamdown-tool'
: 'streamdown-content';
: variant === 'reasoning'
? 'streamdown-content streamdown-reasoning'
: 'streamdown-content';
return (
<div className={cn('break-words w-full min-w-0', className)} ref={streamdownContainerRef}>
File diff suppressed because it is too large Load Diff
+62 -30
View File
@@ -14,6 +14,7 @@ import { useSessionStore } from "@/stores/useSessionStore";
import { useUIStore } from "@/stores/useUIStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
@@ -42,6 +43,19 @@ const priorityIcon: Record<TodoPriority, React.ReactNode> = {
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabel: Record<TodoStatus, string> = {
in_progress: "In progress",
pending: "Pending",
completed: "Completed",
cancelled: "Cancelled",
};
const priorityLabel: Record<TodoPriority, string> = {
high: "High priority",
medium: "Medium priority",
low: "Low priority",
};
interface TodoItemRowProps {
todo: TodoItem;
}
@@ -59,8 +73,15 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
);
return (
<div className="flex items-start min-w-0 py-0.5 gap-2">
<span className="mt-0.5 flex-shrink-0">{statusIcon}</span>
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{statusLabel[todo.status] ?? statusLabel.pending}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
@@ -69,15 +90,21 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
>
{todo.content}
</span>
<span
className={cn(
"typography-meta flex-shrink-0",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
title={`${todo.priority} priority`}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{priorityLabel[todo.priority] ?? priorityLabel.medium}
</TooltipContent>
</Tooltip>
</div>
);
};
@@ -86,8 +113,8 @@ const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking: boolean;
statusText: string | null;
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
@@ -98,11 +125,14 @@ interface StatusRowProps {
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
}
export const StatusRow: React.FC<StatusRowProps> = ({
isWorking,
statusText,
isWorking = false,
statusText = null,
isGenericStatus,
isWaitingForPermission,
wasAborted,
@@ -111,6 +141,9 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
@@ -157,18 +190,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
}, [visibleTodos]);
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
const hasTodoContent = showTodos && hasActiveTodos;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
// Keep StatusRow rendered while:
// - isWorking (active session)
// - wasAborted / showAbortStatus
// - hasActiveTodos
const hasContent =
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus) ||
hasActiveTodos;
const hasContent = hasAssistantContent || hasTodoContent;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -200,7 +231,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : null;
// Todo trigger button
const todoTrigger = hasActiveTodos ? (
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
@@ -232,17 +263,17 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return (
<div className="chat-column mb-1" style={{ containerType: "inline-size" }}>
<div className="flex items-center justify-between pr-[2ch] py-0.5 gap-2 h-[1.2rem]">
<div className="flex items-center justify-between py-0.5 gap-2 h-[1.2rem]">
{/* Left: Abort status or Working placeholder */}
<div className="flex-1 flex items-center overflow-hidden min-w-0">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-[2ch]">
{showAssistantStatus && showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<RiCloseCircleLine size={16} aria-hidden="true" />
Aborted
</span>
</div>
) : shouldRenderPlaceholder ? (
) : showAssistantStatus && shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -250,12 +281,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
isGenericStatus={isGenericStatus}
isWaitingForPermission={isWaitingForPermission}
retryInfo={retryInfo}
agentName={agentName}
/>
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
<div className="relative -mr-3 flex items-center gap-2 flex-shrink-0" ref={popoverRef}>
{abortButton}
{todoTrigger}
@@ -16,7 +16,9 @@ import type { Part } from '@opencode-ai/sdk/v2';
interface TimelineDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onScrollToMessage?: (messageId: string) => void;
onScrollToMessage?: (messageId: string) => void | Promise<boolean>;
onScrollByTurnOffset?: (offset: number) => void;
onResumeToLatest?: () => void;
}
// Helper: format relative time (e.g., "2 hours ago")
@@ -35,7 +37,13 @@ function formatRelativeTime(timestamp: number): string {
return new Date(timestamp).toLocaleDateString();
}
export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChange, onScrollToMessage }) => {
export const TimelineDialog: React.FC<TimelineDialogProps> = ({
open,
onOpenChange,
onScrollToMessage,
onScrollByTurnOffset,
onResumeToLatest,
}) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const messages = useMessageStore((state) =>
currentSessionId ? state.messages.get(currentSessionId) || [] : []
@@ -118,8 +126,11 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChan
<div
key={message.info.id}
className="group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer"
onClick={() => {
onScrollToMessage?.(message.info.id);
onClick={async () => {
const didNavigate = await onScrollToMessage?.(message.info.id);
if (didNavigate === false) {
return;
}
onOpenChange(false);
}}
>
@@ -184,6 +195,29 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({ open, onOpenChan
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="mb-2 flex items-center gap-2">
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
void onScrollByTurnOffset?.(-1);
onOpenChange(false);
}}
>
Previous turn
</button>
<span className="text-muted-foreground/50">/</span>
<button
type="button"
className="text-[11px] uppercase tracking-wide text-muted-foreground/90 hover:text-foreground"
onClick={() => {
onResumeToLatest?.();
onOpenChange(false);
}}
>
Latest
</button>
</div>
<div className="flex flex-col gap-1.5 typography-meta text-muted-foreground">
<div className="flex items-center gap-2">
<span>Click on a message to scroll to it in the conversation</span>
@@ -0,0 +1,33 @@
import React from 'react';
import { RiArrowDownLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface ScrollToBottomButtonProps {
visible: boolean;
onClick: () => void;
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
return (
<div
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
)}
>
<Button
variant="outline"
size="sm"
onClick={onClick}
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label="Scroll to bottom"
>
<RiArrowDownLine className="h-4 w-4" />
</Button>
</div>
);
};
export default React.memo(ScrollToBottomButton);
@@ -0,0 +1,37 @@
import React from 'react';
import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
interface DiffStats {
additions: number;
deletions: number;
files: number;
}
interface TurnActivityProps {
parts: TurnActivityRecord[];
isExpanded: boolean;
collapsedPreviewCount?: number;
onToggle: () => void;
syntaxTheme: Record<string, React.CSSProperties>;
isMobile: boolean;
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
animatedToolIds?: Set<string>;
diffStats?: DiffStats;
}
const TurnActivity: React.FC<TurnActivityProps> = (props) => {
return <ProgressiveGroup {...props} />;
};
export default React.memo(TurnActivity);
@@ -0,0 +1,18 @@
import React from 'react';
import type { ChatMessageEntry } from '../lib/turns/types';
interface TurnAssistantBlockProps {
assistantMessages: ChatMessageEntry[];
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
}
const TurnAssistantBlock: React.FC<TurnAssistantBlockProps> = ({ assistantMessages, renderMessage }) => {
return (
<div className="relative z-0">
{assistantMessages.map((message) => renderMessage(message))}
</div>
);
};
export default React.memo(TurnAssistantBlock);
@@ -0,0 +1,39 @@
import React from 'react';
import type { ChatMessageEntry, Turn } from '../lib/turns/types';
import TurnAssistantBlock from './TurnAssistantBlock';
interface TurnItemProps {
turn: Turn;
stickyUserHeader?: boolean;
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
}
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
return (
<section
className="relative w-full"
id={`turn-${turn.turnId}`}
data-turn-id={turn.turnId}
data-scroll-spy-id={turn.turnId}
>
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
renderMessage(turn.userMessage)
)}
<TurnAssistantBlock assistantMessages={turn.assistantMessages} renderMessage={renderMessage} />
</section>
);
};
export default React.memo(TurnItem);
@@ -0,0 +1,16 @@
import React from 'react';
interface TurnListEntry {
key: string;
}
interface TurnListProps<TEntry extends TurnListEntry> {
entries: TEntry[];
renderEntry: (entry: TEntry) => React.ReactNode;
}
const TurnList = <TEntry extends TurnListEntry>({ entries, renderEntry }: TurnListProps<TEntry>): React.ReactElement => {
return <>{entries.map((entry) => renderEntry(entry))}</>;
};
export default React.memo(TurnList) as typeof TurnList;
@@ -1,656 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import type { TurnGroupingContext as TurnGroupingContextType } from '../hooks/useTurnGrouping';
import { detectTurns, type Turn, type TurnActivityPart, type TurnActivityGroup } from '../hooks/useTurnGrouping';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useUIStore } from '@/stores/useUIStore';
interface ChatMessageEntry {
info: Message;
parts: Part[];
}
interface TurnDiffStats {
additions: number;
deletions: number;
files: number;
}
interface TurnActivityInfo {
activityParts: TurnActivityPart[];
activityGroupSegments: TurnActivityGroup[];
hasTools: boolean;
hasReasoning: boolean;
summaryBody?: string;
diffStats?: TurnDiffStats;
}
interface NeighborInfo {
previousMessage?: ChatMessageEntry;
nextMessage?: ChatMessageEntry;
}
// Static data that only changes when messages change
interface TurnGroupingStaticData {
structureKey: string;
turns: Turn[];
messageToTurn: Map<string, Turn>;
turnActivityInfo: Map<string, TurnActivityInfo>;
lastTurnId: string | null;
lastTurnMessageIds: Set<string>; // Messages belonging to the last turn
defaultActivityExpanded: boolean;
// Neighbor lookup - stable until messages change
messageNeighbors: Map<string, NeighborInfo>;
}
// UI state that changes on user interaction (expand/collapse)
interface TurnGroupingUiStateData {
turnUiStates: Map<string, { isExpanded: boolean }>;
toggleGroup: (turnId: string) => void;
}
// Streaming state that changes frequently during assistant response
interface TurnGroupingStreamingData {
sessionIsWorking: boolean;
lastTurnActivityInfo?: TurnActivityInfo;
}
// Separate contexts to prevent unnecessary re-renders
const TurnGroupingStaticContext = React.createContext<TurnGroupingStaticData | null>(null);
const TurnGroupingUiStateContext = React.createContext<TurnGroupingUiStateData | null>(null);
const TurnGroupingStreamingContext = React.createContext<TurnGroupingStreamingData | null>(null);
const contextCache = new Map<string, TurnGroupingContextType>();
export const useTurnGroupingContextForMessage = (messageId: string): TurnGroupingContextType | undefined => {
const staticData = React.useContext(TurnGroupingStaticContext);
const uiStateData = React.useContext(TurnGroupingUiStateContext);
const streamingData = React.useContext(TurnGroupingStreamingContext);
return React.useMemo(() => {
if (!staticData || !uiStateData || !streamingData) return undefined;
const turn = staticData.messageToTurn.get(messageId);
if (!turn) return undefined;
const isAssistantMessage = turn.assistantMessages.some(
(msg) => msg.info.id === messageId
);
if (!isAssistantMessage) return undefined;
const isLastTurn = staticData.lastTurnId === turn.turnId;
const lastTurnActivityVersion = isLastTurn
? `${streamingData.lastTurnActivityInfo?.activityParts.length ?? 0}:${streamingData.lastTurnActivityInfo?.activityGroupSegments.length ?? 0}:${streamingData.lastTurnActivityInfo?.hasTools ? 1 : 0}:${streamingData.lastTurnActivityInfo?.hasReasoning ? 1 : 0}`
: '';
// Get UI state early - needed for cache key to ensure expand/collapse updates propagate
const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded };
const isExpanded = uiState.isExpanded;
// Cache key must include:
// - messageId: identifies the specific message
// - isExpanded: UI state for this turn's activity group
// - sessionIsWorking (last turn only): streaming state affects "working" indicator
const cacheKey = isLastTurn
? `${staticData.structureKey}:${messageId}-${isExpanded}-${streamingData.sessionIsWorking}-${lastTurnActivityVersion}`
: `${staticData.structureKey}:${messageId}-${isExpanded}`;
const cached = contextCache.get(cacheKey);
if (cached) return cached;
const activityInfo = isLastTurn
? streamingData.lastTurnActivityInfo
: staticData.turnActivityInfo.get(turn.turnId);
const activityParts = activityInfo?.activityParts ?? [];
const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools);
const hasReasoning = Boolean(activityInfo?.hasReasoning);
const summaryBody = activityInfo?.summaryBody;
const diffStats = activityInfo?.diffStats;
const firstAssistantId = turn.assistantMessages[0]?.info.id;
const isFirstAssistantInTurn = messageId === firstAssistantId;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
const isLastAssistantInTurn = messageId === lastAssistantId;
const headerMessageId = firstAssistantId;
// Only the last turn can be "working"
const isTurnWorking = isLastTurn && streamingData.sessionIsWorking;
const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
const context: TurnGroupingContextType = {
turnId: turn.turnId,
isFirstAssistantInTurn,
isLastAssistantInTurn,
summaryBody,
activityParts,
activityGroupSegments,
headerMessageId,
hasTools,
hasReasoning,
diffStats,
userMessageCreatedAt,
isWorking: isTurnWorking,
isGroupExpanded: isExpanded,
toggleGroup: () => uiStateData.toggleGroup(turn.turnId),
};
// Cache with size limit
if (contextCache.size > 500) {
const firstKey = contextCache.keys().next().value;
if (firstKey) contextCache.delete(firstKey);
}
contextCache.set(cacheKey, context);
return context;
}, [staticData, uiStateData, streamingData, messageId]);
};
// Hook to get neighbor messages - uses context instead of passed messages array
export const useMessageNeighbors = (messageId: string): NeighborInfo => {
const staticData = React.useContext(TurnGroupingStaticContext);
// Return stable reference from context - no dependencies on messages array
return React.useMemo(() => {
if (!staticData) return {};
return staticData.messageNeighbors.get(messageId) ?? {};
}, [staticData, messageId]);
};
// Hook to get last turn message IDs - only reads static context
export const useLastTurnMessageIds = (): Set<string> => {
const staticData = React.useContext(TurnGroupingStaticContext);
return staticData?.lastTurnMessageIds ?? new Set();
};
// Static-only version of turn grouping context - does NOT subscribe to streaming context
// Use this for messages NOT in the last turn to avoid re-renders during streaming
// Still subscribes to UI state context for expand/collapse functionality
export const useTurnGroupingContextStatic = (messageId: string): TurnGroupingContextType | undefined => {
const staticData = React.useContext(TurnGroupingStaticContext);
const uiStateData = React.useContext(TurnGroupingUiStateContext);
return React.useMemo(() => {
if (!staticData || !uiStateData) return undefined;
const turn = staticData.messageToTurn.get(messageId);
if (!turn) return undefined;
const isAssistantMessage = turn.assistantMessages.some(
(msg) => msg.info.id === messageId
);
if (!isAssistantMessage) return undefined;
const activityInfo = staticData.turnActivityInfo.get(turn.turnId);
const activityParts = activityInfo?.activityParts ?? [];
const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools);
const hasReasoning = Boolean(activityInfo?.hasReasoning);
const summaryBody = activityInfo?.summaryBody;
const diffStats = activityInfo?.diffStats;
const firstAssistantId = turn.assistantMessages[0]?.info.id;
const isFirstAssistantInTurn = messageId === firstAssistantId;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
const isLastAssistantInTurn = messageId === lastAssistantId;
const headerMessageId = firstAssistantId;
const uiState = uiStateData.turnUiStates.get(turn.turnId) ?? { isExpanded: staticData.defaultActivityExpanded };
const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
// For static context, isWorking is always false (turn is completed)
const context: TurnGroupingContextType = {
turnId: turn.turnId,
isFirstAssistantInTurn,
isLastAssistantInTurn,
summaryBody,
activityParts,
activityGroupSegments,
headerMessageId,
hasTools,
hasReasoning,
diffStats,
userMessageCreatedAt,
isWorking: false,
isGroupExpanded: uiState.isExpanded,
toggleGroup: () => uiStateData.toggleGroup(turn.turnId),
};
return context;
}, [staticData, uiStateData, messageId]);
};
interface TurnGroupingProviderProps {
messages: ChatMessageEntry[];
children: React.ReactNode;
}
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());
};
const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMsg = turn.assistantMessages[messageIndex];
if (!assistantMsg) continue;
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish !== 'stop') continue;
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMsg.parts[partIndex];
if (!part || part.type !== 'text') continue;
const textContent = (part as { text?: string | null | undefined }).text ??
(part as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
}
}
return undefined;
};
const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean): TurnActivityInfo => {
interface SummaryDiff {
additions?: number | null | undefined;
deletions?: number | null | undefined;
file?: string | null | undefined;
}
interface UserSummaryPayload {
body?: string | null | undefined;
diffs?: SummaryDiff[] | null | undefined;
}
const summaryBody = extractFinalAssistantText(turn);
let diffStats: TurnDiffStats | undefined;
const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
const diffs = summary?.diffs;
if (Array.isArray(diffs) && diffs.length > 0) {
let additions = 0;
let deletions = 0;
let files = 0;
diffs.forEach((diff) => {
if (!diff) return;
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
if (diffAdditions !== 0 || diffDeletions !== 0) {
files += 1;
}
additions += diffAdditions;
deletions += diffDeletions;
});
if (files > 0) {
diffStats = { additions, deletions, files };
}
}
let hasTools = false;
let hasReasoning = false;
turn.assistantMessages.forEach((msg) => {
msg.parts.forEach((part) => {
if (part.type === 'tool') hasTools = true;
else if (part.type === 'reasoning') hasReasoning = true;
});
});
// Find the LAST assistant message that has text content - this is the summary
// All other text messages are justification (yapping during work)
let lastTextMessageId: string | undefined;
for (let i = turn.assistantMessages.length - 1; i >= 0; i--) {
const msg = turn.assistantMessages[i];
if (!msg) continue;
const hasText = msg.parts.some((p) => {
if (p.type !== 'text') return false;
const text = (p as { text?: string; content?: string }).text ??
(p as { text?: string; content?: string }).content;
return typeof text === 'string' && text.trim().length > 0;
});
if (hasText) {
lastTextMessageId = msg.info.id;
break;
}
}
const activityParts: TurnActivityPart[] = [];
turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id;
// Only the LAST message with text is the summary (not justification)
// All earlier text messages are justification
const isFinalSummaryMessage = messageId === lastTextMessageId;
msg.parts.forEach((part, partIndex) => {
const baseId = `${messageId}-part-${partIndex}-${part.type}`;
if (part.type === 'tool') {
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
const time = state?.time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'tool',
part,
endedAt: end,
});
return;
}
if (part.type === 'reasoning') {
const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
if (typeof text !== 'string' || text.trim().length === 0) return;
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'reasoning',
part,
endedAt: end,
});
return;
}
if (
showTextJustificationActivity &&
part.type === 'text' &&
(hasTools || hasReasoning) &&
!isFinalSummaryMessage
) {
const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
(part as { text?: string | null | undefined; content?: string | null | undefined }).content;
if (typeof text !== 'string' || text.trim().length === 0) return;
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'justification',
part,
endedAt: end,
});
}
});
});
const activityGroupSegments: TurnActivityGroup[] = [];
const activityByPart = new WeakMap<Part, TurnActivityPart>();
activityParts.forEach((activity) => {
activityByPart.set(activity.part, activity);
});
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
const partsByAfterTool = new Map<string | null, TurnActivityPart[]>();
let currentAfterToolPartId: string | null = null;
turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id;
msg.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
return;
}
}
const activity = activityByPart.get(part);
if (!activity) return;
if (activity.kind === 'tool') {
const toolName = (activity.part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) return;
}
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
list.push(activity);
partsByAfterTool.set(currentAfterToolPartId, list);
});
});
const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => {
if (segmentParts.length === 0) return undefined;
const countByMessage = new Map<string, number>();
segmentParts.forEach((activity) => {
countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const msg of turn.assistantMessages) {
const count = countByMessage.get(msg.info.id) ?? 0;
if (count > 0 && !firstWithAny) firstWithAny = msg.info.id;
cumulative += count;
if (cumulative >= 2) return msg.info.id;
}
return firstWithAny;
};
const orderedKeys: Array<string | null> = [null, ...taskOrder];
orderedKeys.forEach((afterToolPartId) => {
const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
if (segmentParts.length === 0) return;
const anchorMessageId = afterToolPartId === null
? pickAnchorForStartSegment(segmentParts)
: taskMessageById.get(afterToolPartId);
if (!anchorMessageId) return;
activityGroupSegments.push({
id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
anchorMessageId,
afterToolPartId,
parts: segmentParts,
});
});
return {
activityParts,
activityGroupSegments,
hasTools,
hasReasoning,
summaryBody,
diffStats,
};
};
// Build neighbor lookup map from messages
const buildNeighborMap = (messages: ChatMessageEntry[]): Map<string, NeighborInfo> => {
const map = new Map<string, NeighborInfo>();
messages.forEach((message, index) => {
map.set(message.info.id, {
previousMessage: index > 0 ? messages[index - 1] : undefined,
nextMessage: index < messages.length - 1 ? messages[index + 1] : undefined,
});
});
return map;
};
const getMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
return typeof role === 'string' ? role : '';
};
const getStructureKey = (messages: ChatMessageEntry[]): string => {
if (messages.length === 0) return '';
return messages
.map((message) => `${message.info?.id ?? ''}:${getMessageRole(message)}`)
.join('|');
};
export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ messages, children }) => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const defaultActivityExpanded =
toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
const [structuredMessages, setStructuredMessages] = React.useState<ChatMessageEntry[]>(messages);
React.useEffect(() => {
setStructuredMessages((previous) => {
if (getStructureKey(previous) === structureKey) {
return previous;
}
return messages;
});
}, [messages, structureKey]);
const staticStructureKey = React.useMemo(() => getStructureKey(structuredMessages), [structuredMessages]);
const staticValue = React.useMemo<TurnGroupingStaticData>(() => {
const turns = detectTurns(structuredMessages);
const lastTurnId = turns.length > 0 ? turns[turns.length - 1]!.turnId : null;
const messageToTurn = new Map<string, Turn>();
turns.forEach((turn) => {
messageToTurn.set(turn.userMessage.info.id, turn);
turn.assistantMessages.forEach((msg) => {
messageToTurn.set(msg.info.id, turn);
});
});
const turnActivityInfo = new Map<string, TurnActivityInfo>();
turns.forEach((turn) => {
if (turn.turnId === lastTurnId) return;
turnActivityInfo.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
});
const messageNeighbors = buildNeighborMap(structuredMessages);
const lastTurnMessageIds = new Set<string>();
if (turns.length > 0) {
const lastTurn = turns[turns.length - 1]!;
lastTurnMessageIds.add(lastTurn.userMessage.info.id);
lastTurn.assistantMessages.forEach((msg) => {
lastTurnMessageIds.add(msg.info.id);
});
}
return {
structureKey: staticStructureKey,
turns,
messageToTurn,
turnActivityInfo,
lastTurnId,
lastTurnMessageIds,
defaultActivityExpanded,
messageNeighbors,
};
}, [defaultActivityExpanded, showTextJustificationActivity, staticStructureKey, structuredMessages]);
const lastTurnActivityInfo = React.useMemo<TurnActivityInfo | undefined>(() => {
const lastTurnId = staticValue.lastTurnId;
if (!lastTurnId) return undefined;
// Find the last turn's user message in the current messages array to pick up
// streaming content changes without a second full detectTurns() pass.
const turns = staticValue.turns;
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : undefined;
if (!lastTurn) return undefined;
// Re-slice assistant messages from the live `messages` array so that
// streamed part updates are reflected without re-detecting all turns.
const lastTurnUserId = lastTurn.userMessage.info.id;
const liveAssistant: ChatMessageEntry[] = [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const candidate = messages[index];
if (!candidate) {
continue;
}
if (candidate.info.id === lastTurnUserId) {
break;
}
const role = (candidate.info as { clientRole?: string | null }).clientRole ?? candidate.info.role;
if (role === 'assistant') {
liveAssistant.push(candidate);
}
}
if (liveAssistant.length === 0 && messages.every((message) => message.info.id !== lastTurnUserId)) {
return getTurnActivityInfo(lastTurn, showTextJustificationActivity);
}
liveAssistant.reverse();
const liveTurn: Turn = { ...lastTurn, assistantMessages: liveAssistant };
return getTurnActivityInfo(liveTurn, showTextJustificationActivity);
}, [staticValue, messages, showTextJustificationActivity]);
// UI state for expansion toggles
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, { isExpanded: boolean }>>(
() => new Map()
);
// Reset turn UI states when expansion preference changes
React.useEffect(() => {
setTurnUiStates(new Map());
}, [toolCallExpansion]);
const toggleGroup = React.useCallback((turnId: string) => {
setTurnUiStates((prev) => {
const next = new Map(prev);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
// UI state - changes on user interaction (expand/collapse)
const uiStateValue = React.useMemo<TurnGroupingUiStateData>(() => ({
turnUiStates,
toggleGroup,
}), [turnUiStates, toggleGroup]);
// Streaming state - changes frequently during assistant response
const streamingValue = React.useMemo<TurnGroupingStreamingData>(() => ({
sessionIsWorking,
lastTurnActivityInfo,
}), [lastTurnActivityInfo, sessionIsWorking]);
return (
<TurnGroupingStaticContext.Provider value={staticValue}>
<TurnGroupingUiStateContext.Provider value={uiStateValue}>
<TurnGroupingStreamingContext.Provider value={streamingValue}>
{children}
</TurnGroupingStreamingContext.Provider>
</TurnGroupingUiStateContext.Provider>
</TurnGroupingStaticContext.Provider>
);
};
// Clear context cache on unmount or session change
export const clearTurnGroupingCache = (): void => {
contextCache.clear();
};
@@ -0,0 +1,420 @@
import React from 'react';
import type { ChatMessageEntry } from '../lib/turns/types';
import type { MessageListHandle } from '../MessageList';
import { TURN_WINDOW_DEFAULTS } from '../lib/turns/constants';
import {
buildTurnWindowModel,
clampTurnStart,
getInitialTurnStart,
windowMessagesByTurn,
type TurnWindowModel,
} from '../lib/turns/windowTurns';
import type { TurnHistorySignals } from '../lib/turns/historySignals';
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
const waitForFrames = async (count = 1): Promise<void> => {
if (typeof window === 'undefined') {
return;
}
for (let index = 0; index < count; index += 1) {
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});
}
};
type ViewportAnchor = { messageId: string; offsetTop: number };
interface UseChatTimelineControllerOptions {
sessionId: string | null;
messages: ChatMessageEntry[];
historyMeta: SessionHistoryMeta | null;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
loadMoreMessages: (sessionId: string, direction: 'up' | 'down') => Promise<void>;
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
isPinned: boolean;
isOverflowing: boolean;
}
export interface UseChatTimelineControllerResult {
turnIds: string[];
turnStart: number;
renderedMessages: ChatMessageEntry[];
historySignals: TurnHistorySignals;
isLoadingOlder: boolean;
pendingRevealWork: boolean;
activeTurnId: string | null;
showScrollToBottom: boolean;
turnWindowModel: TurnWindowModel;
loadEarlier: () => Promise<void>;
revealBufferedTurns: () => Promise<boolean>;
resumeToBottom: () => void;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
captureViewportAnchor: () => ViewportAnchor | null;
restoreViewportAnchor: (anchor: ViewportAnchor) => boolean;
handleActiveTurnChange: (turnId: string | null) => void;
}
export const useChatTimelineController = ({
sessionId,
messages,
historyMeta,
scrollRef,
messageListRef,
loadMoreMessages,
scrollToBottom,
isPinned,
isOverflowing,
}: UseChatTimelineControllerOptions): UseChatTimelineControllerResult => {
const turnWindowModel = React.useMemo(() => buildTurnWindowModel(messages), [messages]);
const [turnStart, setTurnStart] = React.useState(() => getInitialTurnStart(turnWindowModel.turnCount));
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
const [pendingRevealWork, setPendingRevealWork] = React.useState(false);
const [activeTurnId, setActiveTurnId] = React.useState<string | null>(null);
const turnModelRef = React.useRef(turnWindowModel);
const turnStartRef = React.useRef(turnStart);
const isPinnedRef = React.useRef(isPinned);
const isLoadingOlderRef = React.useRef(isLoadingOlder);
const pendingRevealWorkRef = React.useRef(pendingRevealWork);
const sessionIdRef = React.useRef<string | null>(sessionId);
const messagesRef = React.useRef(messages);
const historyMetaRef = React.useRef<SessionHistoryMeta | null>(historyMeta);
const previousTurnCountRef = React.useRef(turnWindowModel.turnCount);
const initializedSessionRef = React.useRef<string | null>(null);
const historySignals = React.useMemo(() => {
const defaultLimit = getMemoryLimits().HISTORICAL_MESSAGES;
const hasBufferedTurns = turnStart > 0;
const hasMoreAboveTurns = historyMeta
? !historyMeta.complete
: messages.length >= defaultLimit;
const historyLoading = Boolean(historyMeta?.loading);
return {
hasBufferedTurns,
hasMoreAboveTurns,
historyLoading,
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
};
}, [historyMeta, messages.length, turnStart]);
const historySignalsRef = React.useRef(historySignals);
React.useEffect(() => {
turnModelRef.current = turnWindowModel;
}, [turnWindowModel]);
React.useEffect(() => {
turnStartRef.current = turnStart;
}, [turnStart]);
React.useEffect(() => {
isPinnedRef.current = isPinned;
}, [isPinned]);
React.useEffect(() => {
isLoadingOlderRef.current = isLoadingOlder;
}, [isLoadingOlder]);
React.useEffect(() => {
pendingRevealWorkRef.current = pendingRevealWork;
}, [pendingRevealWork]);
React.useEffect(() => {
historySignalsRef.current = historySignals;
}, [historySignals]);
React.useEffect(() => {
sessionIdRef.current = sessionId;
}, [sessionId]);
React.useEffect(() => {
messagesRef.current = messages;
}, [messages]);
React.useEffect(() => {
historyMetaRef.current = historyMeta;
}, [historyMeta]);
React.useEffect(() => {
if (initializedSessionRef.current === sessionId) {
return;
}
initializedSessionRef.current = sessionId;
setTurnStart(getInitialTurnStart(turnWindowModel.turnCount));
setIsLoadingOlder(false);
setPendingRevealWork(false);
setActiveTurnId(null);
previousTurnCountRef.current = turnWindowModel.turnCount;
}, [sessionId, turnWindowModel.turnCount]);
React.useEffect(() => {
setTurnStart((current) => clampTurnStart(current, turnWindowModel.turnCount));
}, [turnWindowModel.turnCount]);
React.useEffect(() => {
const previousTurnCount = previousTurnCountRef.current;
const nextTurnCount = turnWindowModel.turnCount;
if (previousTurnCount === nextTurnCount) {
return;
}
setTurnStart((current) => {
const previousInitial = getInitialTurnStart(previousTurnCount);
const nextInitial = getInitialTurnStart(nextTurnCount);
if (isPinnedRef.current && current === previousInitial) {
return nextInitial;
}
return clampTurnStart(current, nextTurnCount);
});
previousTurnCountRef.current = nextTurnCount;
}, [turnWindowModel.turnCount]);
const renderedMessages = React.useMemo(() => {
return windowMessagesByTurn(messages, turnWindowModel, turnStart);
}, [messages, turnStart, turnWindowModel]);
const captureViewportAnchor = React.useCallback((): ViewportAnchor | null => {
return messageListRef.current?.captureViewportAnchor() ?? null;
}, [messageListRef]);
const restoreViewportAnchor = React.useCallback((anchor: ViewportAnchor): boolean => {
return messageListRef.current?.restoreViewportAnchor(anchor) ?? false;
}, [messageListRef]);
const restoreViewportWithFallback = React.useCallback((input: {
anchor: ViewportAnchor | null;
previousHeight: number | null;
previousTop: number | null;
}) => {
const container = scrollRef.current;
if (input.anchor && restoreViewportAnchor(input.anchor)) {
return;
}
if (!container || input.previousHeight === null || input.previousTop === null) {
return;
}
const heightDelta = container.scrollHeight - input.previousHeight;
if (heightDelta !== 0) {
container.scrollTop = input.previousTop + heightDelta;
}
}, [restoreViewportAnchor, scrollRef]);
const revealBufferedTurns = React.useCallback(async (): Promise<boolean> => {
if (turnStartRef.current <= 0 || pendingRevealWorkRef.current) {
return false;
}
const anchor = captureViewportAnchor();
const container = scrollRef.current;
const previousHeight = container?.scrollHeight ?? null;
const previousTop = container?.scrollTop ?? null;
setPendingRevealWork(true);
setTurnStart((current) => {
const next = current - TURN_WINDOW_DEFAULTS.batchTurns;
return next > 0 ? next : 0;
});
await waitForFrames(1);
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
setPendingRevealWork(false);
return true;
}, [captureViewportAnchor, restoreViewportWithFallback, scrollRef]);
const fetchOlderHistory = React.useCallback(async (input: {
preserveViewport: boolean;
}): Promise<boolean> => {
if (!sessionIdRef.current || isLoadingOlderRef.current) {
return false;
}
if (!historySignalsRef.current.hasMoreAboveTurns) {
return false;
}
const anchor = input.preserveViewport ? captureViewportAnchor() : null;
const container = scrollRef.current;
const previousHeight = input.preserveViewport ? (container?.scrollHeight ?? null) : null;
const previousTop = input.preserveViewport ? (container?.scrollTop ?? null) : null;
const beforeMessages = messagesRef.current;
const beforeMessageCount = beforeMessages.length;
const beforeOldestMessageId = beforeMessages[0]?.info?.id ?? null;
const beforeLimit = historyMetaRef.current?.limit ?? getMemoryLimits().HISTORICAL_MESSAGES;
setPendingRevealWork(true);
setIsLoadingOlder(true);
try {
const targetSessionId = sessionIdRef.current;
if (!targetSessionId) {
return false;
}
await loadMoreMessages(targetSessionId, 'up');
const afterMessages = messagesRef.current;
const afterMessageCount = afterMessages.length;
const afterOldestMessageId = afterMessages[0]?.info?.id ?? null;
const afterLimit = historyMetaRef.current?.limit ?? beforeLimit;
const historyGrew =
afterMessageCount > beforeMessageCount
|| (typeof beforeOldestMessageId === 'string'
&& typeof afterOldestMessageId === 'string'
&& beforeOldestMessageId !== afterOldestMessageId);
if (input.preserveViewport) {
restoreViewportWithFallback({
anchor,
previousHeight,
previousTop,
});
}
return historyGrew || afterLimit > beforeLimit;
} finally {
setIsLoadingOlder(false);
setPendingRevealWork(false);
}
}, [captureViewportAnchor, loadMoreMessages, restoreViewportWithFallback, scrollRef]);
const loadEarlier = React.useCallback(async () => {
if (await revealBufferedTurns()) {
return;
}
void (await fetchOlderHistory({ preserveViewport: true }));
}, [fetchOlderHistory, revealBufferedTurns]);
const scrollToTurn = React.useCallback(async (
turnId: string,
options?: { behavior?: ScrollBehavior },
): Promise<boolean> => {
if (!turnId || !sessionIdRef.current) {
return false;
}
setPendingRevealWork(true);
try {
if (sessionIdRef.current !== sessionId) {
return false;
}
const turnIndex = turnModelRef.current.turnIndexById.get(turnId);
if (typeof turnIndex !== 'number') {
return false;
}
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
if (didScroll) {
setActiveTurnId(turnId);
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToTurnId(turnId, {
behavior: options?.behavior,
}) ?? false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
const scrollToMessage = React.useCallback(async (
messageId: string,
options?: { behavior?: ScrollBehavior },
): Promise<boolean> => {
if (!messageId || !sessionIdRef.current) {
return false;
}
setPendingRevealWork(true);
try {
if (sessionIdRef.current !== sessionId) {
return false;
}
const turnId = turnModelRef.current.messageToTurnId.get(messageId);
const turnIndex = turnModelRef.current.messageToTurnIndex.get(messageId);
if (typeof turnIndex !== 'number') {
return false;
}
if (turnIndex < turnStartRef.current) {
setTurnStart(turnIndex);
await waitForFrames(2);
}
const didScroll = messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
if (didScroll) {
if (turnId) {
setActiveTurnId(turnId);
}
return true;
}
await waitForFrames(2);
return messageListRef.current?.scrollToMessageId(messageId, {
behavior: options?.behavior,
}) ?? false;
} finally {
setPendingRevealWork(false);
}
}, [messageListRef, sessionId]);
const resumeToBottom = React.useCallback(() => {
const nextStart = getInitialTurnStart(turnModelRef.current.turnCount);
setTurnStart(nextStart);
setPendingRevealWork(false);
setIsLoadingOlder(false);
scrollToBottom({ force: true });
}, [scrollToBottom]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
setActiveTurnId(turnId);
}, []);
return {
turnIds: turnWindowModel.turnIds,
turnStart,
renderedMessages,
historySignals,
isLoadingOlder,
pendingRevealWork,
activeTurnId,
showScrollToBottom: isOverflowing && !isPinned && !pendingRevealWork,
turnWindowModel,
loadEarlier,
revealBufferedTurns,
resumeToBottom,
scrollToTurn,
scrollToMessage,
captureViewportAnchor,
restoreViewportAnchor,
handleActiveTurnChange,
};
};
@@ -0,0 +1,189 @@
import React from 'react';
export type ChatHashTarget =
| { kind: 'turn'; id: string }
| { kind: 'message'; id: string };
export const parseChatHashTarget = (hashValue: string): ChatHashTarget | null => {
const value = hashValue.startsWith('#') ? hashValue.slice(1) : hashValue;
if (!value) {
return null;
}
const turnMatch = value.match(/^turn-(.+)$/);
if (turnMatch?.[1]) {
return { kind: 'turn', id: turnMatch[1] };
}
const messageMatch = value.match(/^message-(.+)$/);
if (messageMatch?.[1]) {
return { kind: 'message', id: messageMatch[1] };
}
return null;
};
type TurnOffsetTarget =
| { kind: 'noop' }
| { kind: 'resume' }
| { kind: 'turn'; turnId: string };
export const resolveTurnOffsetTarget = (
turnIds: string[],
activeTurnId: string | null,
offset: number,
): TurnOffsetTarget => {
if (offset === 0) {
return { kind: 'noop' };
}
if (turnIds.length === 0) {
return { kind: 'noop' };
}
const baseIndex = activeTurnId ? turnIds.indexOf(activeTurnId) : turnIds.length - 1;
const normalizedBase = baseIndex >= 0 ? baseIndex : turnIds.length - 1;
const targetIndex = normalizedBase + offset;
if (targetIndex >= turnIds.length) {
return { kind: 'resume' };
}
const clampedTarget = Math.max(0, targetIndex);
const targetTurnId = turnIds[clampedTarget];
if (!targetTurnId) {
return { kind: 'noop' };
}
return { kind: 'turn', turnId: targetTurnId };
};
const setHash = (hash: string | null): void => {
if (typeof window === 'undefined') {
return;
}
const nextHash = hash ? `#${hash}` : '';
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash}`;
window.history.replaceState(window.history.state, '', nextUrl);
};
interface UseChatTurnNavigationOptions {
sessionId: string | null;
turnIds: string[];
activeTurnId: string | null;
scrollToTurn: (turnId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
scrollToMessage: (messageId: string, options?: { behavior?: ScrollBehavior }) => Promise<boolean>;
resumeToBottom: () => void;
}
export interface ChatTurnNavigation {
scrollToTurnId: (turnId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior; updateHash?: boolean }) => Promise<boolean>;
scrollByTurnOffset: (offset: number) => Promise<boolean>;
resumeToLatest: () => void;
}
export const useChatTurnNavigation = ({
sessionId,
turnIds,
activeTurnId,
scrollToTurn,
scrollToMessage,
resumeToBottom,
}: UseChatTurnNavigationOptions): ChatTurnNavigation => {
const turnIdsRef = React.useRef(turnIds);
const activeTurnIdRef = React.useRef(activeTurnId);
React.useEffect(() => {
turnIdsRef.current = turnIds;
}, [turnIds]);
React.useEffect(() => {
activeTurnIdRef.current = activeTurnId;
}, [activeTurnId]);
const scrollToTurnId = React.useCallback(async (
turnId: string,
options?: { behavior?: ScrollBehavior; updateHash?: boolean },
): Promise<boolean> => {
if (!turnId) {
return false;
}
if (options?.updateHash !== false) {
setHash(`turn-${turnId}`);
}
return scrollToTurn(turnId, { behavior: options?.behavior });
}, [scrollToTurn]);
const scrollToMessageId = React.useCallback(async (
messageId: string,
options?: { behavior?: ScrollBehavior; updateHash?: boolean },
): Promise<boolean> => {
if (!messageId) {
return false;
}
if (options?.updateHash !== false) {
setHash(`message-${messageId}`);
}
return scrollToMessage(messageId, { behavior: options?.behavior });
}, [scrollToMessage]);
const scrollByTurnOffset = React.useCallback(async (offset: number): Promise<boolean> => {
const target = resolveTurnOffsetTarget(turnIdsRef.current, activeTurnIdRef.current, offset);
if (target.kind === 'noop') {
return offset === 0;
}
if (target.kind === 'resume') {
setHash(null);
resumeToBottom();
return true;
}
return scrollToTurnId(target.turnId, { behavior: 'auto' });
}, [resumeToBottom, scrollToTurnId]);
const resumeToLatest = React.useCallback(() => {
setHash(null);
resumeToBottom();
}, [resumeToBottom]);
React.useEffect(() => {
if (!sessionId || typeof window === 'undefined') {
return;
}
const applyHash = () => {
const target = parseChatHashTarget(window.location.hash);
if (!target) {
return;
}
if (target.kind === 'turn') {
void scrollToTurnId(target.id, { behavior: 'auto', updateHash: false });
return;
}
void scrollToMessageId(target.id, { behavior: 'auto', updateHash: false });
};
applyHash();
window.addEventListener('hashchange', applyHash);
return () => {
window.removeEventListener('hashchange', applyHash);
};
}, [sessionId, scrollToMessageId, scrollToTurnId, turnIds.length]);
return {
scrollToTurnId,
scrollToMessageId,
scrollByTurnOffset,
resumeToLatest,
};
};
@@ -0,0 +1,105 @@
import React from 'react';
interface UseStreamingTextThrottleInput {
text: string;
isStreaming: boolean;
throttleMs?: number;
identityKey?: string;
}
const DEFAULT_STREAMING_TEXT_THROTTLE_MS = 100;
export const computeStreamingThrottleDelay = (lastEmitAt: number, now: number, throttleMs: number): number => {
const elapsed = now - lastEmitAt;
return Math.max(0, throttleMs - elapsed);
};
interface StreamingThrottleState {
timer: ReturnType<typeof setTimeout> | null;
pendingText: string;
lastEmitAt: number;
}
const clearTimer = (state: StreamingThrottleState): void => {
if (!state.timer) {
return;
}
clearTimeout(state.timer);
state.timer = null;
};
export const useStreamingTextThrottle = ({
text,
isStreaming,
throttleMs = DEFAULT_STREAMING_TEXT_THROTTLE_MS,
identityKey,
}: UseStreamingTextThrottleInput): string => {
const [throttledText, setThrottledText] = React.useState(text);
const latestTextRef = React.useRef(text);
const stateRef = React.useRef<StreamingThrottleState>({
timer: null,
pendingText: text,
lastEmitAt: 0,
});
React.useEffect(() => {
latestTextRef.current = text;
}, [text]);
React.useEffect(() => {
const state = stateRef.current;
clearTimer(state);
state.pendingText = latestTextRef.current;
state.lastEmitAt = 0;
setThrottledText(latestTextRef.current);
}, [identityKey]);
React.useEffect(() => {
const state = stateRef.current;
state.pendingText = text;
const stableText = isStreaming && throttledText.length > text.length ? throttledText : text;
if (!isStreaming) {
clearTimer(state);
state.lastEmitAt = Date.now();
setThrottledText(stableText);
return;
}
const now = Date.now();
const remaining = computeStreamingThrottleDelay(state.lastEmitAt, now, throttleMs);
if (remaining <= 0) {
clearTimer(state);
state.lastEmitAt = now;
setThrottledText(stableText);
return;
}
clearTimer(state);
state.timer = setTimeout(() => {
state.timer = null;
state.lastEmitAt = Date.now();
setThrottledText((prev) => {
if (isStreaming && prev.length > state.pendingText.length) {
return prev;
}
return state.pendingText;
});
}, remaining);
return () => {
clearTimer(state);
};
}, [isStreaming, text, throttleMs, throttledText]);
React.useEffect(() => {
const state = stateRef.current;
return () => {
clearTimer(state);
};
}, []);
return throttledText;
};
@@ -1,517 +0,0 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useUIStore } from '@/stores/useUIStore';
export interface ChatMessageEntry {
info: Message;
parts: Part[];
}
export interface Turn {
turnId: string;
userMessage: ChatMessageEntry;
assistantMessages: ChatMessageEntry[];
}
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
export interface TurnActivityPart {
id: string;
turnId: string;
messageId: string;
kind: TurnActivityKind;
part: Part;
endedAt?: number;
}
interface TurnDiffStats {
additions: number;
deletions: number;
files: number;
}
export interface TurnActivityGroup {
id: string;
anchorMessageId: string;
afterToolPartId: string | null;
parts: TurnActivityPart[];
}
export interface TurnGroupingContext {
turnId: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
summaryBody?: string;
activityParts: TurnActivityPart[];
activityGroupSegments: TurnActivityGroup[];
headerMessageId?: string;
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
userMessageCreatedAt?: number;
isWorking: boolean;
isGroupExpanded: boolean;
toggleGroup: () => void;
}
interface TurnUiState {
isExpanded: boolean;
}
interface TurnActivityInfo {
activityParts: TurnActivityPart[];
activityGroupSegments: TurnActivityGroup[];
hasTools: boolean;
hasReasoning: boolean;
summaryBody?: string;
diffStats?: TurnDiffStats;
}
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());
};
export const detectTurns = (messages: ChatMessageEntry[]): Turn[] => {
const result: Turn[] = [];
let currentTurn: Turn | null = null;
messages.forEach((msg) => {
const role = (msg.info as { clientRole?: string | null | undefined }).clientRole ?? msg.info.role;
if (role === 'user') {
currentTurn = {
turnId: msg.info.id,
userMessage: msg,
assistantMessages: [],
};
result.push(currentTurn);
} else if (role === 'assistant' && currentTurn) {
currentTurn.assistantMessages.push(msg);
}
});
return result;
};
const extractFinalAssistantText = (turn: Turn): string | undefined => {
for (let messageIndex = turn.assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMsg = turn.assistantMessages[messageIndex];
if (!assistantMsg) continue;
const infoFinish = (assistantMsg.info as { finish?: string | null | undefined }).finish;
if (infoFinish !== 'stop') continue;
for (let partIndex = assistantMsg.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMsg.parts[partIndex];
if (!part || part.type !== 'text') continue;
const textContent = (part as { text?: string | null | undefined }).text ??
(part as { content?: string | null | undefined }).content;
if (typeof textContent === 'string' && textContent.trim().length > 0) {
return textContent;
}
}
}
return undefined;
};
const getTurnActivityInfo = (turn: Turn, showTextJustificationActivity: boolean): TurnActivityInfo => {
interface SummaryDiff {
additions?: number | null | undefined;
deletions?: number | null | undefined;
file?: string | null | undefined;
}
interface UserSummaryPayload {
body?: string | null | undefined;
diffs?: SummaryDiff[] | null | undefined;
}
const summaryBody = extractFinalAssistantText(turn);
let diffStats: TurnDiffStats | undefined;
const summary = (turn.userMessage.info as { summary?: UserSummaryPayload | null | undefined }).summary;
const diffs = summary?.diffs;
if (Array.isArray(diffs) && diffs.length > 0) {
let additions = 0;
let deletions = 0;
let files = 0;
diffs.forEach((diff) => {
if (!diff) {
return;
}
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
if (diffAdditions !== 0 || diffDeletions !== 0) {
files += 1;
}
additions += diffAdditions;
deletions += diffDeletions;
});
if (files > 0) {
diffStats = {
additions,
deletions,
files,
};
}
}
let hasTools = false;
let hasReasoning = false;
turn.assistantMessages.forEach((msg) => {
msg.parts.forEach((part) => {
if (part.type === 'tool') {
hasTools = true;
} else if (part.type === 'reasoning') {
hasReasoning = true;
}
});
});
// Find the LAST assistant message that has text content - this is the summary
// All other text messages are justification (yapping during work)
let lastTextMessageId: string | undefined;
for (let i = turn.assistantMessages.length - 1; i >= 0; i--) {
const msg = turn.assistantMessages[i];
if (!msg) continue;
const hasText = msg.parts.some((p) => {
if (p.type !== 'text') return false;
const text = (p as { text?: string; content?: string }).text ??
(p as { text?: string; content?: string }).content;
return typeof text === 'string' && text.trim().length > 0;
});
if (hasText) {
lastTextMessageId = msg.info.id;
break;
}
}
const activityParts: TurnActivityPart[] = [];
turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id;
// Only the LAST message with text is the summary (not justification)
// All earlier text messages are justification
const isFinalSummaryMessage = messageId === lastTextMessageId;
msg.parts.forEach((part, partIndex) => {
const baseId = `${messageId}-part-${partIndex}-${part.type}`;
if (part.type === 'tool') {
const state = (part as { state?: { time?: { end?: number | null | undefined } | null | undefined } | null | undefined }).state;
const time = state?.time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'tool',
part,
endedAt: end,
});
return;
}
if (part.type === 'reasoning') {
const text = (part as { text?: string | null | undefined; content?: string | null | undefined }).text
?? (part as { text?: string | null | undefined; content?: string | null | undefined }).content;
if (typeof text !== 'string' || text.trim().length === 0) {
return;
}
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'reasoning',
part,
endedAt: end,
});
return;
}
if (
showTextJustificationActivity &&
part.type === 'text' &&
(hasTools || hasReasoning) &&
!isFinalSummaryMessage
) {
const text =
(part as { text?: string | null | undefined; content?: string | null | undefined }).text ??
(part as { text?: string | null | undefined; content?: string | null | undefined }).content;
if (typeof text !== 'string' || text.trim().length === 0) {
return;
}
const time = (part as { time?: { end?: number | null | undefined } | null | undefined }).time;
const end = typeof time?.end === 'number' ? time.end : undefined;
activityParts.push({
id: baseId,
turnId: turn.turnId,
messageId,
kind: 'justification',
part,
endedAt: end,
});
}
});
});
const activityGroupSegments: TurnActivityGroup[] = [];
const activityByPart = new WeakMap<Part, TurnActivityPart>();
activityParts.forEach((activity) => {
activityByPart.set(activity.part, activity);
});
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
const partsByAfterTool = new Map<string | null, TurnActivityPart[]>();
let currentAfterToolPartId: string | null = null;
turn.assistantMessages.forEach((msg) => {
const messageId = msg.info.id;
msg.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
const toolPartId = `${messageId}-part-${partIndex}-${part.type}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
return;
}
}
const activity = activityByPart.get(part);
if (!activity) {
return;
}
if (activity.kind === 'tool') {
const toolName = (activity.part as { tool?: unknown }).tool;
if (isActivityStandaloneTool(toolName)) {
return;
}
}
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
list.push(activity);
partsByAfterTool.set(currentAfterToolPartId, list);
});
});
const pickAnchorForStartSegment = (segmentParts: TurnActivityPart[]): string | undefined => {
if (segmentParts.length === 0) return undefined;
const countByMessage = new Map<string, number>();
segmentParts.forEach((activity) => {
countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const msg of turn.assistantMessages) {
const count = countByMessage.get(msg.info.id) ?? 0;
if (count > 0 && !firstWithAny) {
firstWithAny = msg.info.id;
}
cumulative += count;
if (cumulative >= 2) {
return msg.info.id;
}
}
return firstWithAny;
};
const orderedKeys: Array<string | null> = [null, ...taskOrder];
orderedKeys.forEach((afterToolPartId) => {
const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
if (segmentParts.length === 0) {
return;
}
const anchorMessageId = afterToolPartId === null
? pickAnchorForStartSegment(segmentParts)
: taskMessageById.get(afterToolPartId);
if (!anchorMessageId) {
return;
}
activityGroupSegments.push({
id: `${turn.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
anchorMessageId,
afterToolPartId,
parts: segmentParts,
});
});
return {
activityParts,
activityGroupSegments,
hasTools,
hasReasoning,
summaryBody,
diffStats,
};
};
interface UseTurnGroupingResult {
turns: Turn[];
getTurnForMessage: (messageId: string) => Turn | undefined;
getContextForMessage: (messageId: string) => TurnGroupingContext | undefined;
}
export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingResult => {
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
const turns = React.useMemo(() => detectTurns(messages), [messages]);
const lastTurnId = React.useMemo(() => {
if (turns.length === 0) return null;
return turns[turns.length - 1]!.turnId;
}, [turns]);
const messageToTurn = React.useMemo(() => {
const map = new Map<string, Turn>();
turns.forEach((turn) => {
map.set(turn.userMessage.info.id, turn);
turn.assistantMessages.forEach((msg) => {
map.set(msg.info.id, turn);
});
});
return map;
}, [turns]);
const turnActivityInfo = React.useMemo(() => {
const map = new Map<string, TurnActivityInfo>();
turns.forEach((turn) => {
map.set(turn.turnId, getTurnActivityInfo(turn, showTextJustificationActivity));
});
return map;
}, [turns, showTextJustificationActivity]);
const [turnUiStates, setTurnUiStates] = React.useState<Map<string, TurnUiState>>(
() => new Map()
);
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
// Activity group is expanded for 'activity', 'detailed', and 'changes'; collapsed for 'collapsed'
const defaultActivityExpanded =
toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
// Reset turn UI states when the expansion preference changes
// This ensures the setting takes precedence over manual toggles
React.useEffect(() => {
setTurnUiStates(new Map());
}, [toolCallExpansion]);
const getOrCreateTurnState = React.useCallback(
(turnId: string): TurnUiState => {
const existing = turnUiStates.get(turnId);
if (existing) return existing;
return { isExpanded: defaultActivityExpanded };
},
[turnUiStates, defaultActivityExpanded]
);
const toggleGroup = React.useCallback((turnId: string) => {
setTurnUiStates((prev) => {
const next = new Map(prev);
const current = next.get(turnId) ?? { isExpanded: defaultActivityExpanded };
next.set(turnId, { isExpanded: !current.isExpanded });
return next;
});
}, [defaultActivityExpanded]);
const getTurnForMessage = React.useCallback(
(messageId: string): Turn | undefined => {
return messageToTurn.get(messageId);
},
[messageToTurn]
);
const getContextForMessage = React.useCallback(
(messageId: string): TurnGroupingContext | undefined => {
const turn = messageToTurn.get(messageId);
if (!turn) return undefined;
const isAssistantMessage = turn.assistantMessages.some(
(msg) => msg.info.id === messageId
);
if (!isAssistantMessage) return undefined;
const activityInfo = turnActivityInfo.get(turn.turnId);
const activityParts = activityInfo?.activityParts ?? [];
const activityGroupSegments = activityInfo?.activityGroupSegments ?? [];
const hasTools = Boolean(activityInfo?.hasTools);
const hasReasoning = Boolean(activityInfo?.hasReasoning);
const summaryBody = activityInfo?.summaryBody;
const diffStats = activityInfo?.diffStats;
const firstAssistantId = turn.assistantMessages[0]?.info.id;
const isFirstAssistantInTurn = messageId === firstAssistantId;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
const isLastAssistantInTurn = messageId === lastAssistantId;
const headerMessageId = firstAssistantId;
const uiState = getOrCreateTurnState(turn.turnId);
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
return {
turnId: turn.turnId,
isFirstAssistantInTurn,
isLastAssistantInTurn,
summaryBody,
activityParts,
activityGroupSegments,
headerMessageId,
hasTools,
hasReasoning,
diffStats,
userMessageCreatedAt,
isWorking: isTurnWorking,
isGroupExpanded: uiState.isExpanded,
toggleGroup: () => toggleGroup(turn.turnId),
} satisfies TurnGroupingContext;
},
[getOrCreateTurnState, lastTurnId, messageToTurn, sessionIsWorking, toggleGroup, turnActivityInfo]
);
return {
turns,
getTurnForMessage,
getContextForMessage,
};
};
@@ -0,0 +1,26 @@
import React from 'react';
import type { TurnProjectionResult } from '../lib/turns/types';
export interface TurnLookupResult {
turnById: TurnProjectionResult['indexes']['turnById'];
messageToTurnId: TurnProjectionResult['indexes']['messageToTurnId'];
messageMetaById: TurnProjectionResult['indexes']['messageMetaById'];
getTurnByMessageId: (messageId: string) => TurnProjectionResult['turns'][number] | undefined;
}
export const useTurnLookup = (projection: TurnProjectionResult): TurnLookupResult => {
const getTurnByMessageId = React.useCallback((messageId: string) => {
const turnId = projection.indexes.messageToTurnId.get(messageId);
if (!turnId) {
return undefined;
}
return projection.indexes.turnById.get(turnId);
}, [projection.indexes.messageToTurnId, projection.indexes.turnById]);
return {
turnById: projection.indexes.turnById,
messageToTurnId: projection.indexes.messageToTurnId,
messageMetaById: projection.indexes.messageMetaById,
getTurnByMessageId,
};
};
@@ -0,0 +1,55 @@
import React from 'react';
import { projectTurnRecords } from '../lib/turns/projectTurnRecords';
import { stabilizeTurnProjection } from '../lib/turns/stabilizeTurnProjection';
import type { ChatMessageEntry, TurnProjectionResult } from '../lib/turns/types';
interface UseTurnRecordsOptions {
showTextJustificationActivity: boolean;
}
export interface TurnRecordsResult {
projection: TurnProjectionResult;
staticTurns: TurnProjectionResult['turns'];
streamingTurn: TurnProjectionResult['turns'][number] | undefined;
}
export const useTurnRecords = (
messages: ChatMessageEntry[],
options: UseTurnRecordsOptions,
): TurnRecordsResult => {
const previousProjectionRef = React.useRef<TurnProjectionResult | null>(null);
React.useEffect(() => {
previousProjectionRef.current = null;
}, [options.showTextJustificationActivity]);
const projection = React.useMemo(() => {
const rawProjection = projectTurnRecords(messages, {
previousProjection: previousProjectionRef.current,
showTextJustificationActivity: options.showTextJustificationActivity,
});
const stabilizedProjection = stabilizeTurnProjection(rawProjection, previousProjectionRef.current);
previousProjectionRef.current = stabilizedProjection;
return stabilizedProjection;
}, [messages, options.showTextJustificationActivity]);
const staticTurns = React.useMemo(() => {
if (projection.turns.length <= 1) {
return [];
}
return projection.turns.slice(0, -1);
}, [projection.turns]);
const streamingTurn = React.useMemo(() => {
if (projection.turns.length === 0) {
return undefined;
}
return projection.turns[projection.turns.length - 1];
}, [projection.turns]);
return {
projection,
staticTurns,
streamingTurn,
};
};
@@ -0,0 +1,47 @@
interface SessionLinkRecord {
id: string;
parentID?: string;
}
export const collectVisibleSessionIdsForBlockingRequests = (
sessions: SessionLinkRecord[] | undefined,
currentSessionId: string | null,
): string[] => {
if (!currentSessionId) return [];
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
const current = sessions.find((session) => session.id === currentSessionId);
if (!current) return [currentSessionId];
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
if (current.parentID) {
return [];
}
const childIds = sessions
.filter((session) => session.parentID === currentSessionId)
.map((session) => session.id);
return [currentSessionId, ...childIds];
};
export const flattenBlockingRequests = <T extends { id: string }>(
source: Map<string, T[]>,
sessionIds: string[],
): T[] => {
if (sessionIds.length === 0) return [];
const seen = new Set<string>();
const result: T[] = [];
for (const sessionId of sessionIds) {
const entries = source.get(sessionId);
if (!entries || entries.length === 0) continue;
for (const entry of entries) {
if (seen.has(entry.id)) continue;
seen.add(entry.id);
result.push(entry);
}
}
return result;
};
@@ -0,0 +1,78 @@
export const normalizeWheelDelta = (input: {
deltaY: number;
deltaMode: number;
rootHeight?: number;
}): number => {
if (input.deltaMode === 1) {
return input.deltaY * 40;
}
if (input.deltaMode === 2) {
return input.deltaY * (input.rootHeight ?? 120);
}
return input.deltaY;
};
export const shouldMarkBoundaryGesture = (input: {
delta: number;
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}): boolean => {
const max = input.scrollHeight - input.clientHeight;
if (max <= 1) {
return true;
}
if (!input.delta) {
return false;
}
if (input.delta < 0) {
return input.scrollTop + input.delta <= 0;
}
const remaining = max - input.scrollTop;
return input.delta > remaining;
};
export const boundaryTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement => {
const current = target instanceof Element ? target : undefined;
const nested = current?.closest('[data-scrollable]');
if (!nested || nested === root) {
return root;
}
if (!(nested instanceof HTMLElement)) {
return root;
}
return nested;
};
export const shouldPauseAutoScrollOnWheel = (input: {
root: HTMLElement;
target: EventTarget | null;
delta: number;
}): boolean => {
if (input.delta >= 0) {
return false;
}
const target = boundaryTarget(input.root, input.target);
if (target === input.root) {
return true;
}
return shouldMarkBoundaryGesture({
delta: input.delta,
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
});
};
export const isNearTop = (scrollTop: number, threshold: number): boolean => {
return scrollTop <= threshold;
};
export const isNearBottom = (distanceFromBottom: number, threshold: number): boolean => {
return distanceFromBottom <= threshold;
};
@@ -0,0 +1,315 @@
export type VisibleTurn = {
id: string;
ratio: number;
top: number;
};
export type OffsetTurn = {
id: string;
top: number;
};
type ScrollSpyInput = {
onActive: (id: string) => void;
raf?: (cb: FrameRequestCallback) => number;
caf?: (id: number) => void;
IntersectionObserver?: typeof globalThis.IntersectionObserver;
ResizeObserver?: typeof globalThis.ResizeObserver;
MutationObserver?: typeof globalThis.MutationObserver;
};
export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
if (list.length === 0) {
return undefined;
}
const sorted = [...list].sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
const distanceA = Math.abs(a.top - line);
const distanceB = Math.abs(b.top - line);
if (distanceA !== distanceB) {
return distanceA - distanceB;
}
return a.top - b.top;
});
return sorted[0]?.id;
};
export const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
if (list.length === 0) {
return undefined;
}
let lo = 0;
let hi = list.length - 1;
let out = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const top = list[mid]?.top;
if (top === undefined) {
break;
}
if (top <= cutoff) {
out = mid;
lo = mid + 1;
continue;
}
hi = mid - 1;
}
return list[out]?.id;
};
export const createScrollSpy = (input: ScrollSpyInput) => {
const raf = input.raf ?? requestAnimationFrame;
const caf = input.caf ?? cancelAnimationFrame;
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver;
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
let root: HTMLDivElement | undefined;
let io: IntersectionObserver | undefined;
let ro: ResizeObserver | undefined;
let mo: MutationObserver | undefined;
let frame: number | undefined;
let active: string | undefined;
let dirty = true;
const nodes = new Map<string, HTMLElement>();
const idByElement = new WeakMap<HTMLElement, string>();
const visible = new Map<string, { ratio: number; top: number }>();
let offsets: OffsetTurn[] = [];
const schedule = () => {
if (frame !== undefined) {
return;
}
frame = raf(() => {
frame = undefined;
update();
});
};
const refreshOffsets = () => {
const container = root;
if (!container) {
offsets = [];
dirty = false;
return;
}
const baseTop = container.getBoundingClientRect().top;
offsets = [...nodes].map(([key, element]) => ({
id: key,
top: element.getBoundingClientRect().top - baseTop + container.scrollTop,
}));
offsets.sort((a, b) => a.top - b.top);
dirty = false;
};
const update = () => {
const container = root;
if (!container) {
return;
}
const line = container.getBoundingClientRect().top + 100;
const next =
pickVisibleTurnId(
[...visible].map(([id, value]) => ({
id,
ratio: value.ratio,
top: value.top,
})),
line,
)
?? (() => {
if (dirty) {
refreshOffsets();
}
return pickOffsetTurnId(offsets, container.scrollTop + 100);
})();
if (!next || next === active) {
return;
}
active = next;
input.onActive(next);
};
const observe = () => {
const container = root;
if (!container) {
return;
}
io?.disconnect();
io = undefined;
if (CtorIO) {
try {
io = new CtorIO(
(entries) => {
for (const entry of entries) {
const element = entry.target;
if (!(element instanceof HTMLElement)) {
continue;
}
const key = idByElement.get(element);
if (!key) {
continue;
}
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
visible.delete(key);
continue;
}
visible.set(key, {
ratio: entry.intersectionRatio,
top: entry.boundingClientRect.top,
});
}
schedule();
},
{
root: container,
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
} catch {
io = undefined;
}
}
if (io) {
for (const element of nodes.values()) {
io.observe(element);
}
}
ro?.disconnect();
ro = undefined;
if (CtorRO) {
ro = new CtorRO(() => {
dirty = true;
schedule();
});
ro.observe(container);
for (const element of nodes.values()) {
ro.observe(element);
}
}
mo?.disconnect();
mo = undefined;
if (CtorMO) {
mo = new CtorMO(() => {
dirty = true;
schedule();
});
mo.observe(container, { subtree: true, childList: true, characterData: true });
}
dirty = true;
schedule();
};
const setContainer = (element?: HTMLDivElement) => {
if (root === element) {
return;
}
root = element;
visible.clear();
active = undefined;
observe();
};
const register = (element: HTMLElement, key: string) => {
const previous = nodes.get(key);
if (previous && previous !== element) {
io?.unobserve(previous);
ro?.unobserve(previous);
}
nodes.set(key, element);
idByElement.set(element, key);
if (io) {
io.observe(element);
}
if (ro) {
ro.observe(element);
}
dirty = true;
schedule();
};
const unregister = (key: string) => {
const element = nodes.get(key);
if (!element) {
return;
}
io?.unobserve(element);
ro?.unobserve(element);
nodes.delete(key);
visible.delete(key);
dirty = true;
schedule();
};
const markDirty = () => {
dirty = true;
schedule();
};
const clear = () => {
for (const element of nodes.values()) {
io?.unobserve(element);
ro?.unobserve(element);
}
nodes.clear();
visible.clear();
offsets = [];
active = undefined;
dirty = true;
};
const destroy = () => {
if (frame !== undefined) {
caf(frame);
}
frame = undefined;
clear();
io?.disconnect();
ro?.disconnect();
mo?.disconnect();
io = undefined;
ro = undefined;
mo = undefined;
root = undefined;
};
return {
setContainer,
register,
unregister,
onScroll: schedule,
markDirty,
clear,
destroy,
getActiveId: () => active,
};
};
@@ -0,0 +1,91 @@
import type { Message } from '@opencode-ai/sdk/v2';
import type { ChatMessageEntry } from './types';
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
const info = message.info as { clientRole?: string | null; role?: string | null };
return (typeof info.clientRole === 'string' ? info.clientRole : null)
?? (typeof info.role === 'string' ? info.role : null)
?? null;
};
interface RetryOverlayInput {
sessionId: string | null;
message: string;
confirmedAt?: number;
fallbackTimestamp: number;
}
export const applyRetryOverlay = (
messages: ChatMessageEntry[],
input: RetryOverlayInput,
): ChatMessageEntry[] => {
if (!input.sessionId) {
return messages;
}
const retryError = {
name: 'SessionRetry',
message: input.message,
data: { message: input.message },
};
let lastUserIndex = -1;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (resolveMessageRole(messages[index]) === 'user') {
lastUserIndex = index;
break;
}
}
if (lastUserIndex < 0) {
return messages;
}
let targetAssistantIndex = -1;
for (let index = messages.length - 1; index > lastUserIndex; index -= 1) {
if (resolveMessageRole(messages[index]) === 'assistant') {
targetAssistantIndex = index;
break;
}
}
if (targetAssistantIndex >= 0) {
const existing = messages[targetAssistantIndex];
const existingInfo = existing.info as { error?: unknown };
if (existingInfo.error) {
return messages;
}
return messages.map((message, index) => {
if (index !== targetAssistantIndex) {
return message;
}
return {
...message,
info: {
...(message.info as Record<string, unknown>),
error: retryError,
} as unknown as Message,
};
});
}
const eventTime = typeof input.confirmedAt === 'number' ? input.confirmedAt : input.fallbackTimestamp;
const syntheticId = `synthetic_retry_notice_${input.sessionId}`;
const synthetic: ChatMessageEntry = {
info: {
id: syntheticId,
sessionID: input.sessionId,
role: 'assistant',
time: { created: eventTime, completed: eventTime },
finish: 'stop',
error: retryError,
} as unknown as Message,
parts: [],
};
const next = messages.slice();
next.splice(lastUserIndex + 1, 0, synthetic);
return next;
};
@@ -0,0 +1,11 @@
export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
export const TURN_WINDOW_DEFAULTS = {
initialTurns: 10,
batchTurns: 8,
prefetchBuffer: 16,
} as const;
export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
@@ -0,0 +1,67 @@
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
export interface TurnHistorySignalsInput {
memoryState: SessionMemoryState | null;
loadedMessageCount: number;
loadedTurnCount: number;
turnStart: number;
defaultHistoryLimit: number;
}
export interface TurnHistorySignals {
hasBufferedTurns: boolean;
hasMoreAboveTurns: boolean;
historyLoading: boolean;
canLoadEarlier: boolean;
}
const deriveHasMoreAbove = (
memoryState: SessionMemoryState | null,
loadedMessageCount: number,
loadedTurnCount: number,
defaultHistoryLimit: number,
): boolean => {
if (!memoryState) {
return loadedMessageCount >= defaultHistoryLimit;
}
if (memoryState.historyComplete === true) {
return false;
}
if (memoryState.hasMoreTurnsAbove === true || memoryState.hasMoreAbove === true) {
return true;
}
if (memoryState.historyComplete === false) {
return true;
}
if (memoryState.hasMoreTurnsAbove === false || memoryState.hasMoreAbove === false) {
return false;
}
const fallbackMessageSignal = loadedMessageCount >= defaultHistoryLimit;
const fallbackTurnSignal = loadedTurnCount >= Math.max(1, Math.floor(defaultHistoryLimit / 2));
return fallbackMessageSignal || fallbackTurnSignal;
};
export const deriveTurnHistorySignals = (
input: TurnHistorySignalsInput,
): TurnHistorySignals => {
const hasBufferedTurns = input.turnStart > 0;
const hasMoreAboveTurns = deriveHasMoreAbove(
input.memoryState,
input.loadedMessageCount,
input.loadedTurnCount,
input.defaultHistoryLimit,
);
const historyLoading = Boolean(input.memoryState?.historyLoading);
return {
hasBufferedTurns,
hasMoreAboveTurns,
historyLoading,
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
};
};
@@ -0,0 +1,243 @@
import { ACTIVITY_STANDALONE_TOOL_NAMES } from './constants';
import type {
ChatMessageEntry,
TurnActivityGroup,
TurnActivityRecord,
TurnPartRecord,
} from './types';
const isStandaloneTool = (toolName: unknown): boolean => {
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
};
const getPartEndTime = (part: unknown): number | undefined => {
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end;
if (typeof stateEnd === 'number') {
return stateEnd;
}
const timeEnd = (part as { time?: { end?: unknown } }).time?.end;
return typeof timeEnd === 'number' ? timeEnd : undefined;
};
const getPartText = (part: unknown): string | undefined => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string' && text.trim().length > 0) {
return text;
}
const content = (part as { content?: unknown }).content;
if (typeof content === 'string' && content.trim().length > 0) {
return content;
}
return undefined;
};
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
const finish = (message.info as { finish?: unknown }).finish;
return typeof finish === 'string' ? finish : undefined;
};
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
const completed = info.time?.completed;
const status = info.status;
if (typeof completed !== 'number' || completed <= 0) {
return false;
}
if (typeof status === 'string') {
return status === 'completed';
}
return true;
};
const buildTurnPartRecord = (
turnId: string,
messageId: string,
part: ChatMessageEntry['parts'][number],
partIndex: number,
): TurnPartRecord => {
return {
id: part.id ?? `${messageId}-part-${partIndex}-${part.type}`,
turnId,
messageId,
part,
partIndex,
endedAt: getPartEndTime(part),
};
};
interface ProjectActivityInput {
turnId: string;
assistantMessages: ChatMessageEntry[];
summarySourceMessageId?: string;
showTextJustificationActivity: boolean;
}
interface ProjectActivityResult {
activityParts: TurnActivityRecord[];
activitySegments: TurnActivityGroup[];
hasTools: boolean;
hasReasoning: boolean;
}
export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => {
const activityParts: TurnActivityRecord[] = [];
const hasTools = input.assistantMessages.some((message) =>
message.parts.some((part) => part.type === 'tool')
);
const hasReasoning = input.assistantMessages.some((message) =>
message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part)))
);
input.assistantMessages.forEach((message) => {
message.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'tool',
});
return;
}
if (part.type === 'reasoning') {
const text = getPartText(part);
if (!text) {
return;
}
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'reasoning',
});
return;
}
if (!input.showTextJustificationActivity || part.type !== 'text') {
return;
}
if (!isAssistantMessageCompleted(message)) {
return;
}
const finish = getMessageFinish(message);
if (!finish) {
return;
}
if (finish === 'stop') {
return;
}
const text = getPartText(part);
if (!text) {
return;
}
activityParts.push({
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
kind: 'justification',
});
});
});
const activitySegments: TurnActivityGroup[] = [];
const activityByPart = new WeakMap<object, TurnActivityRecord>();
activityParts.forEach((activity) => {
activityByPart.set(activity.part as object, activity);
});
const taskMessageById = new Map<string, string>();
const taskOrder: string[] = [];
const partsByAfterTool = new Map<string | null, TurnActivityRecord[]>();
let currentAfterToolPartId: string | null = null;
input.assistantMessages.forEach((message) => {
const messageId = message.info.id;
message.parts.forEach((part, partIndex) => {
if (part.type === 'tool') {
const toolName = (part as { tool?: unknown }).tool;
if (isStandaloneTool(toolName)) {
const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`;
if (!taskMessageById.has(toolPartId)) {
taskMessageById.set(toolPartId, messageId);
taskOrder.push(toolPartId);
}
currentAfterToolPartId = toolPartId;
return;
}
}
const activity = activityByPart.get(part as object);
if (!activity) {
return;
}
if (activity.kind === 'tool') {
const toolName = (activity.part as { tool?: unknown }).tool;
if (isStandaloneTool(toolName)) {
return;
}
}
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
list.push(activity);
partsByAfterTool.set(currentAfterToolPartId, list);
});
});
const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => {
if (segmentParts.length === 0) {
return undefined;
}
const countByMessage = new Map<string, number>();
segmentParts.forEach((activity) => {
countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
});
let firstWithAny: string | undefined;
let cumulative = 0;
for (const message of input.assistantMessages) {
const count = countByMessage.get(message.info.id) ?? 0;
if (count > 0 && !firstWithAny) {
firstWithAny = message.info.id;
}
cumulative += count;
if (cumulative >= 2) {
return message.info.id;
}
}
return firstWithAny;
};
const orderedKeys: Array<string | null> = [null, ...taskOrder];
orderedKeys.forEach((afterToolPartId) => {
const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
if (segmentParts.length === 0) {
return;
}
const anchorMessageId = afterToolPartId === null
? pickStartAnchor(segmentParts)
: taskMessageById.get(afterToolPartId);
if (!anchorMessageId) {
return;
}
activitySegments.push({
id: `${input.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
anchorMessageId,
afterToolPartId,
parts: segmentParts,
});
});
return {
activityParts,
activitySegments,
hasTools,
hasReasoning,
};
};
@@ -0,0 +1,64 @@
import type { TurnIndexes, TurnProjectionResult, TurnRecord } from './types';
export const projectTurnIndexes = (turns: TurnRecord[]): TurnProjectionResult => {
const turnById = new Map<string, TurnRecord>();
const messageToTurnId = new Map<string, string>();
const messageMetaById: TurnIndexes['messageMetaById'] = new Map();
const groupedMessageIds = new Set<string>();
turns.forEach((turn) => {
turnById.set(turn.turnId, turn);
const firstAssistantId = turn.assistantMessages[0]?.info.id;
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
messageToTurnId.set(turn.userMessageId, turn.turnId);
groupedMessageIds.add(turn.userMessageId);
messageMetaById.set(turn.userMessageId, {
turnId: turn.turnId,
messageId: turn.userMessageId,
userMessageId: turn.userMessageId,
isUserMessage: true,
isAssistantMessage: false,
isFirstAssistantInTurn: false,
isLastAssistantInTurn: false,
headerMessageId: turn.headerMessageId,
});
turn.assistantMessageIds.forEach((assistantMessageId) => {
messageToTurnId.set(assistantMessageId, turn.turnId);
groupedMessageIds.add(assistantMessageId);
messageMetaById.set(assistantMessageId, {
turnId: turn.turnId,
messageId: assistantMessageId,
userMessageId: turn.userMessageId,
isUserMessage: false,
isAssistantMessage: true,
isFirstAssistantInTurn: assistantMessageId === firstAssistantId,
isLastAssistantInTurn: assistantMessageId === lastAssistantId,
headerMessageId: turn.headerMessageId,
});
});
});
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
const lastTurnMessageIds = new Set<string>();
if (lastTurn) {
lastTurnMessageIds.add(lastTurn.userMessageId);
lastTurn.assistantMessageIds.forEach((messageId) => {
lastTurnMessageIds.add(messageId);
});
}
return {
turns,
indexes: {
turnById,
messageToTurnId,
messageMetaById,
},
lastTurnId: lastTurn?.turnId ?? null,
lastTurnMessageIds,
ungroupedMessageIds: new Set<string>(),
};
};
@@ -0,0 +1,327 @@
import { projectTurnActivity } from './projectTurnActivity';
import { projectTurnIndexes } from './projectTurnIndexes';
import { projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
import type {
ChatMessageEntry,
TurnMessageRecord,
TurnProjectionResult,
TurnRecord,
TurnStreamState,
} from './types';
const resolveMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role;
return typeof role === 'string' ? role : '';
};
const getMessageParentId = (message: ChatMessageEntry): string | undefined => {
const parentId = (message.info as { parentID?: unknown }).parentID;
if (typeof parentId !== 'string' || parentId.trim().length === 0) {
return undefined;
}
return parentId;
};
const getMessageCreatedAt = (message: ChatMessageEntry): number | undefined => {
const created = (message.info as { time?: { created?: unknown } }).time?.created;
return typeof created === 'number' ? created : undefined;
};
const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined => {
const completed = (message.info as { time?: { completed?: unknown } }).time?.completed;
return typeof completed === 'number' ? completed : undefined;
};
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
const finish = (message.info as { finish?: unknown }).finish;
return typeof finish === 'string' ? finish : undefined;
};
const getMessageStatus = (message: ChatMessageEntry): string | undefined => {
const status = (message.info as { status?: unknown }).status;
return typeof status === 'string' ? status : undefined;
};
const getPartText = (part: ChatMessageEntry['parts'][number]): string | undefined => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') {
return text;
}
const content = (part as { content?: unknown }).content;
return typeof content === 'string' ? content : undefined;
};
const arePartsEquivalentForReuse = (
previousPart: ChatMessageEntry['parts'][number],
nextPart: ChatMessageEntry['parts'][number],
): boolean => {
if (previousPart === nextPart) {
return true;
}
if (previousPart.type !== nextPart.type) {
return false;
}
if (previousPart.id && nextPart.id && previousPart.id !== nextPart.id) {
return false;
}
if (previousPart.type === 'text' || previousPart.type === 'reasoning') {
return getPartText(previousPart) === getPartText(nextPart);
}
if (previousPart.type === 'tool') {
const previousTool = previousPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
const nextTool = nextPart as {
tool?: unknown;
callID?: unknown;
state?: { status?: unknown };
};
return previousTool.tool === nextTool.tool
&& previousTool.callID === nextTool.callID
&& previousTool.state?.status === nextTool.state?.status;
}
return true;
};
const areMessagesEquivalentForReuse = (previousMessage: ChatMessageEntry, nextMessage: ChatMessageEntry): boolean => {
if (previousMessage === nextMessage) {
return true;
}
if (previousMessage.info.id !== nextMessage.info.id) {
return false;
}
if (getMessageCompletedAt(previousMessage) !== getMessageCompletedAt(nextMessage)) {
return false;
}
if (getMessageFinish(previousMessage) !== getMessageFinish(nextMessage)) {
return false;
}
if (getMessageStatus(previousMessage) !== getMessageStatus(nextMessage)) {
return false;
}
if (previousMessage.parts.length !== nextMessage.parts.length) {
return false;
}
for (let index = 0; index < previousMessage.parts.length; index += 1) {
if (!arePartsEquivalentForReuse(previousMessage.parts[index], nextMessage.parts[index])) {
return false;
}
}
return true;
};
const getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
if (typeof summaryBody !== 'string') {
return undefined;
}
const trimmed = summaryBody.trim();
return trimmed.length > 0 ? summaryBody : undefined;
};
const createTurnMessageRecord = (message: ChatMessageEntry, order: number): TurnMessageRecord => {
const role = resolveMessageRole(message);
return {
messageId: message.info.id,
role,
parentMessageId: getMessageParentId(message),
message,
order,
};
};
const buildTurnStreamState = (userMessage: ChatMessageEntry, assistantMessages: ChatMessageEntry[]): TurnStreamState => {
const startedAt = getMessageCreatedAt(userMessage);
let completedAt: number | undefined;
let isStreaming = false;
assistantMessages.forEach((message) => {
const completed = getMessageCompletedAt(message);
if (typeof completed === 'number') {
completedAt = Math.max(completedAt ?? 0, completed);
} else {
isStreaming = true;
}
});
const durationMs = typeof startedAt === 'number' && typeof completedAt === 'number' && completedAt >= startedAt
? completedAt - startedAt
: undefined;
return {
isStreaming,
isRetrying: assistantMessages.length > 1,
startedAt,
completedAt,
durationMs,
};
};
interface ProjectTurnRecordsOptions {
previousProjection?: TurnProjectionResult | null;
showTextJustificationActivity: boolean;
}
const DEFAULT_OPTIONS: ProjectTurnRecordsOptions = {
previousProjection: null,
showTextJustificationActivity: false,
};
export const projectTurnRecords = (
messages: ChatMessageEntry[],
options?: Partial<ProjectTurnRecordsOptions>,
): TurnProjectionResult => {
const effectiveOptions: ProjectTurnRecordsOptions = {
...DEFAULT_OPTIONS,
...options,
};
const turns: TurnRecord[] = [];
const turnByUserId = new Map<string, TurnRecord>();
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
const groupedMessageIds = new Set<string>();
let currentTurn: TurnRecord | undefined;
messages.forEach((message, index) => {
const role = resolveMessageRole(message);
if (role === 'user') {
const turnId = message.info.id;
const turn: TurnRecord = {
turnId,
userMessageId: message.info.id,
userMessage: message,
headerMessageId: undefined,
messages: [createTurnMessageRecord(message, index)],
assistantMessageIds: [],
assistantMessages: [],
activityParts: [],
activitySegments: [],
summary: {},
summaryText: undefined,
hasTools: false,
hasReasoning: false,
diffStats: undefined,
stream: {
isStreaming: false,
isRetrying: false,
},
};
turns.push(turn);
turnByUserId.set(turn.userMessageId, turn);
groupedMessageIds.add(message.info.id);
currentTurn = turn;
return;
}
if (role !== 'assistant') {
return;
}
const parentId = getMessageParentId(message);
const parentTurn = parentId ? turnByUserId.get(parentId) : undefined;
const targetTurn = parentTurn ?? currentTurn;
if (!targetTurn) {
return;
}
targetTurn.assistantMessages.push(message);
targetTurn.assistantMessageIds.push(message.info.id);
targetTurn.messages.push(createTurnMessageRecord(message, index));
if (!targetTurn.headerMessageId) {
targetTurn.headerMessageId = message.info.id;
}
groupedMessageIds.add(message.info.id);
if (!parentTurn) {
currentTurn = targetTurn;
}
});
turns.forEach((turn) => {
const previousTurn = previousTurnsById.get(turn.turnId);
const canReuseComputed = (() => {
if (!previousTurn) {
return false;
}
if (previousTurn.stream.isStreaming) {
return false;
}
if (!areMessagesEquivalentForReuse(previousTurn.userMessage, turn.userMessage)) {
return false;
}
if (previousTurn.assistantMessages.length !== turn.assistantMessages.length) {
return false;
}
for (let index = 0; index < turn.assistantMessages.length; index += 1) {
if (!areMessagesEquivalentForReuse(previousTurn.assistantMessages[index], turn.assistantMessages[index])) {
return false;
}
}
return true;
})();
if (canReuseComputed && previousTurn) {
turn.summary = previousTurn.summary;
turn.summaryText = previousTurn.summaryText;
turn.diffStats = previousTurn.diffStats;
turn.activityParts = previousTurn.activityParts;
turn.activitySegments = previousTurn.activitySegments;
turn.hasTools = previousTurn.hasTools;
turn.hasReasoning = previousTurn.hasReasoning;
turn.stream = previousTurn.stream;
turn.startedAt = previousTurn.startedAt;
turn.completedAt = previousTurn.completedAt;
turn.durationMs = previousTurn.durationMs;
return;
}
turn.summary = projectTurnSummary(turn.assistantMessages);
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
turn.diffStats = projectTurnDiffStats(turn.userMessage);
const activity = projectTurnActivity({
turnId: turn.turnId,
assistantMessages: turn.assistantMessages,
summarySourceMessageId: turn.summary.sourceMessageId,
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
});
turn.activityParts = activity.activityParts;
turn.activitySegments = activity.activitySegments;
turn.hasTools = activity.hasTools;
turn.hasReasoning = activity.hasReasoning;
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
turn.startedAt = turn.stream.startedAt;
turn.completedAt = turn.stream.completedAt;
turn.durationMs = turn.stream.durationMs;
});
const projection = projectTurnIndexes(turns);
const ungroupedMessageIds = new Set<string>();
messages.forEach((message) => {
if (!groupedMessageIds.has(message.info.id)) {
ungroupedMessageIds.add(message.info.id);
}
});
return {
...projection,
ungroupedMessageIds,
};
};
@@ -0,0 +1,104 @@
import type { ChatMessageEntry, TurnDiffStats, TurnSummaryRecord } from './types';
interface SummaryDiff {
additions?: number | null;
deletions?: number | null;
}
interface UserSummaryPayload {
body?: string | null;
diffs?: SummaryDiff[] | null;
}
const getTextFromPart = (part: unknown): string | undefined => {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string' && text.trim().length > 0) {
return text;
}
const content = (part as { content?: unknown }).content;
if (typeof content === 'string' && content.trim().length > 0) {
return content;
}
return undefined;
};
export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnSummaryRecord => {
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMessage = assistantMessages[messageIndex];
if (!assistantMessage) continue;
const finish = (assistantMessage.info as { finish?: string | null }).finish;
if (finish !== 'stop') continue;
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMessage.parts[partIndex];
if (!part || part.type !== 'text') continue;
const text = getTextFromPart(part);
if (!text) continue;
return {
text,
sourceMessageId: assistantMessage.info.id,
sourcePartId: part.id ?? `${assistantMessage.info.id}-part-${partIndex}-text`,
};
}
}
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const assistantMessage = assistantMessages[messageIndex];
if (!assistantMessage) continue;
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
const part = assistantMessage.parts[partIndex];
if (!part || part.type !== 'text') continue;
const text = getTextFromPart(part);
if (!text) continue;
return {
text,
sourceMessageId: assistantMessage.info.id,
sourcePartId: part.id ?? `${assistantMessage.info.id}-part-${partIndex}-text`,
};
}
}
return {};
};
export const projectTurnDiffStats = (userMessage: ChatMessageEntry): TurnDiffStats | undefined => {
const summary = (userMessage.info as { summary?: UserSummaryPayload | null }).summary;
const diffs = summary?.diffs;
if (!Array.isArray(diffs) || diffs.length === 0) {
return undefined;
}
let additions = 0;
let deletions = 0;
let files = 0;
diffs.forEach((diff) => {
if (!diff) return;
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
if (diffAdditions !== 0 || diffDeletions !== 0) {
files += 1;
}
additions += diffAdditions;
deletions += diffDeletions;
});
if (files === 0) {
return undefined;
}
return {
additions,
deletions,
files,
};
};
@@ -0,0 +1,56 @@
import { projectTurnIndexes } from './projectTurnIndexes';
import type { TurnProjectionResult, TurnRecord } from './types';
const buildTurnSignature = (turn: TurnRecord): string => {
const assistantIds = turn.assistantMessageIds.join(',');
return [
turn.turnId,
turn.headerMessageId ?? '',
assistantIds,
turn.summaryText ?? '',
turn.stream.isStreaming ? '1' : '0',
turn.stream.isRetrying ? '1' : '0',
turn.completedAt ?? '',
].join('|');
};
export const stabilizeTurnProjection = (
nextProjection: TurnProjectionResult,
previousProjection: TurnProjectionResult | null,
): TurnProjectionResult => {
if (!previousProjection || previousProjection.turns.length === 0 || nextProjection.turns.length === 0) {
return nextProjection;
}
const previousById = new Map(previousProjection.turns.map((turn) => [turn.turnId, turn]));
let reused = false;
const stabilizedTurns = nextProjection.turns.map((turn, index) => {
const isLastTurn = index === nextProjection.turns.length - 1;
if (isLastTurn) {
return turn;
}
const previousTurn = previousById.get(turn.turnId);
if (!previousTurn) {
return turn;
}
if (buildTurnSignature(previousTurn) !== buildTurnSignature(turn)) {
return turn;
}
reused = true;
return previousTurn;
});
if (!reused) {
return nextProjection;
}
const projection = projectTurnIndexes(stabilizedTurns);
return {
...projection,
ungroupedMessageIds: nextProjection.ungroupedMessageIds,
};
};
@@ -0,0 +1,159 @@
import React from 'react';
export interface TurnStageConfig {
init: number;
batch: number;
}
export interface UseStageTurnsOptions {
sessionKey: string;
turnStart: number;
totalTurns: number;
config?: Partial<TurnStageConfig>;
disabled?: boolean;
}
export interface StageTurnsResult {
stagedCount: number;
stageStartIndex: number;
isStaging: boolean;
}
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
init: 1,
batch: 3,
};
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
if (total <= 0) {
return 0;
}
return Math.min(total, Math.max(1, config.init));
};
export const getNextStageCount = (current: number, total: number, config: TurnStageConfig): number => {
if (total <= 0) {
return 0;
}
const batch = Math.max(1, config.batch);
return Math.min(total, current + batch);
};
export const getStageStartIndex = (total: number, stagedCount: number): number => {
if (stagedCount >= total) {
return 0;
}
return Math.max(0, total - stagedCount);
};
export const useStageTurns = ({
sessionKey,
turnStart,
totalTurns,
config,
disabled,
}: UseStageTurnsOptions): StageTurnsResult => {
const effectiveConfig = React.useMemo<TurnStageConfig>(() => {
return {
init: config?.init ?? DEFAULT_STAGE_CONFIG.init,
batch: config?.batch ?? DEFAULT_STAGE_CONFIG.batch,
};
}, [config?.batch, config?.init]);
const [state, setState] = React.useState(() => ({
activeSession: '',
completedSession: '',
count: totalTurns,
}));
const stateRef = React.useRef(state);
React.useEffect(() => {
stateRef.current = state;
}, [state]);
React.useEffect(() => {
let frameId: number | null = null;
const snapshot = stateRef.current;
const shouldStage =
!disabled
&& turnStart > 0
&& totalTurns > effectiveConfig.init
&& snapshot.completedSession !== sessionKey
&& snapshot.activeSession !== sessionKey;
if (!shouldStage) {
setState((previous) => {
if (previous.count === totalTurns && previous.activeSession === '') {
return previous;
}
return {
...previous,
activeSession: '',
count: totalTurns,
};
});
return () => {
if (frameId !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(frameId);
}
};
}
let nextCount = getInitialStageCount(totalTurns, effectiveConfig);
setState((previous) => ({
...previous,
activeSession: sessionKey,
count: nextCount,
}));
const step = () => {
nextCount = getNextStageCount(nextCount, totalTurns, effectiveConfig);
setState((previous) => ({
...previous,
count: nextCount,
}));
if (nextCount >= totalTurns) {
setState((previous) => ({
...previous,
completedSession: sessionKey,
activeSession: '',
count: totalTurns,
}));
frameId = null;
return;
}
frameId = window.requestAnimationFrame(step);
};
if (typeof window !== 'undefined') {
frameId = window.requestAnimationFrame(step);
}
return () => {
if (frameId !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(frameId);
}
};
}, [disabled, effectiveConfig, sessionKey, totalTurns, turnStart]);
const stagedCount = React.useMemo(() => {
if (turnStart <= 0 || disabled) {
return totalTurns;
}
if (state.completedSession === sessionKey) {
return totalTurns;
}
if (state.count <= 0) {
return getInitialStageCount(totalTurns, effectiveConfig);
}
return Math.min(totalTurns, state.count);
}, [disabled, effectiveConfig, sessionKey, state.completedSession, state.count, totalTurns, turnStart]);
return {
stagedCount,
stageStartIndex: getStageStartIndex(totalTurns, stagedCount),
isStaging: !disabled && turnStart > 0 && state.activeSession === sessionKey && state.completedSession !== sessionKey,
};
};
@@ -0,0 +1,122 @@
import type { Message, Part } from '@opencode-ai/sdk/v2';
export interface ChatMessageEntry {
info: Message;
parts: Part[];
}
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
export interface TurnMessageRecord {
messageId: string;
role: string;
parentMessageId?: string;
message: ChatMessageEntry;
order: number;
}
export interface TurnPartRecord {
id: string;
turnId: string;
messageId: string;
part: Part;
partIndex: number;
endedAt?: number;
}
export interface TurnActivityRecord extends TurnPartRecord {
kind: TurnActivityKind;
}
export interface TurnDiffStats {
additions: number;
deletions: number;
files: number;
}
export interface TurnActivityGroup {
id: string;
anchorMessageId: string;
afterToolPartId: string | null;
parts: TurnActivityRecord[];
}
export interface TurnSummaryRecord {
text?: string;
sourceMessageId?: string;
sourcePartId?: string;
}
export interface TurnStreamState {
isStreaming: boolean;
isRetrying: boolean;
startedAt?: number;
completedAt?: number;
durationMs?: number;
}
export interface TurnRecord {
turnId: string;
userMessageId: string;
userMessage: ChatMessageEntry;
headerMessageId?: string;
messages: TurnMessageRecord[];
assistantMessageIds: string[];
assistantMessages: ChatMessageEntry[];
activityParts: TurnActivityRecord[];
activitySegments: TurnActivityGroup[];
summary: TurnSummaryRecord;
summaryText?: string;
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
stream: TurnStreamState;
startedAt?: number;
completedAt?: number;
durationMs?: number;
}
export interface TurnMessageMeta {
turnId: string;
messageId: string;
userMessageId: string;
isUserMessage: boolean;
isAssistantMessage: boolean;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
headerMessageId?: string;
}
export interface TurnIndexes {
turnById: Map<string, TurnRecord>;
messageToTurnId: Map<string, string>;
messageMetaById: Map<string, TurnMessageMeta>;
}
export interface TurnProjectionResult {
turns: TurnRecord[];
indexes: TurnIndexes;
lastTurnId: string | null;
lastTurnMessageIds: Set<string>;
ungroupedMessageIds: Set<string>;
}
export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessages'>;
export interface TurnGroupingContext {
turnId: string;
isFirstAssistantInTurn: boolean;
isLastAssistantInTurn: boolean;
summaryBody?: string;
activityParts?: TurnActivityRecord[];
activityGroupSegments?: TurnActivityGroup[];
headerMessageId?: string;
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
userMessageCreatedAt?: number;
userMessageVariant?: string;
isWorking: boolean;
isGroupExpanded?: boolean;
toggleGroup?: () => void;
}
@@ -0,0 +1,129 @@
import type { ChatMessageEntry } from './types';
import { TURN_WINDOW_DEFAULTS } from './constants';
const resolveMessageRole = (message: ChatMessageEntry): string => {
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role;
return typeof role === 'string' ? role : '';
};
const resolveParentMessageId = (message: ChatMessageEntry): string | undefined => {
const parentId = (message.info as { parentID?: unknown }).parentID;
if (typeof parentId !== 'string' || parentId.trim().length === 0) {
return undefined;
}
return parentId;
};
export interface TurnWindowModel {
turnIds: string[];
turnMessageStartIndexes: number[];
turnIndexById: Map<string, number>;
messageToTurnId: Map<string, string>;
messageToTurnIndex: Map<string, number>;
turnCount: number;
}
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
const turnIds: string[] = [];
const turnMessageStartIndexes: number[] = [];
const turnIndexById = new Map<string, number>();
const messageToTurnId = new Map<string, string>();
const messageToTurnIndex = new Map<string, number>();
const userMessageToTurnIndex = new Map<string, number>();
let currentTurnIndex = -1;
messages.forEach((message, index) => {
const role = resolveMessageRole(message);
const messageId = message.info.id;
if (role === 'user') {
currentTurnIndex = turnIds.length;
turnIds.push(messageId);
turnMessageStartIndexes.push(index);
turnIndexById.set(messageId, currentTurnIndex);
userMessageToTurnIndex.set(messageId, currentTurnIndex);
messageToTurnId.set(messageId, messageId);
messageToTurnIndex.set(messageId, currentTurnIndex);
return;
}
if (role !== 'assistant') {
if (currentTurnIndex >= 0) {
const turnId = turnIds[currentTurnIndex];
if (turnId) {
messageToTurnId.set(messageId, turnId);
messageToTurnIndex.set(messageId, currentTurnIndex);
}
}
return;
}
const parentId = resolveParentMessageId(message);
const parentTurnIndex = parentId ? userMessageToTurnIndex.get(parentId) : undefined;
const targetTurnIndex = typeof parentTurnIndex === 'number' ? parentTurnIndex : currentTurnIndex;
if (targetTurnIndex < 0) {
return;
}
const turnId = turnIds[targetTurnIndex];
if (!turnId) {
return;
}
messageToTurnId.set(messageId, turnId);
messageToTurnIndex.set(messageId, targetTurnIndex);
});
return {
turnIds,
turnMessageStartIndexes,
turnIndexById,
messageToTurnId,
messageToTurnIndex,
turnCount: turnIds.length,
};
};
export const getInitialTurnStart = (
turnCount: number,
initialTurns = TURN_WINDOW_DEFAULTS.initialTurns,
): number => {
if (turnCount <= 0) {
return 0;
}
return turnCount > initialTurns ? turnCount - initialTurns : 0;
};
export const clampTurnStart = (turnStart: number, turnCount: number): number => {
if (turnCount <= 0) {
return 0;
}
if (turnStart <= 0) {
return 0;
}
return Math.min(turnStart, turnCount - 1);
};
export const getTurnWindowSliceStart = (
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
turnStart: number,
): number => {
if (turnStart <= 0) {
return 0;
}
const from = model.turnMessageStartIndexes[turnStart];
return typeof from === 'number' ? from : 0;
};
export const windowMessagesByTurn = (
messages: ChatMessageEntry[],
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
turnStart: number,
): ChatMessageEntry[] => {
const sliceStart = getTurnWindowSliceStart(model, turnStart);
if (sliceStart <= 0) {
return messages;
}
return messages.slice(sliceStart);
};
@@ -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;
};
@@ -30,10 +30,11 @@ export function InlineCommentCard({
const themeContext = useOptionalThemeSystem();
const currentTheme = themeContext?.currentTheme;
const [isOpen, setIsOpen] = useState(false);
const draftText = typeof draft.text === 'string' ? draft.text : '';
// Check if content is long enough to warrant collapsing (rough estimate)
// In a real app we might measure line height, but length check is a good proxy for now
const isLongContent = draft.text.length > 150 || draft.text.split('\n').length > 3;
const isLongContent = draftText.length > 150 || draftText.split('\n').length > 3;
return (
<div
@@ -61,7 +62,7 @@ export function InlineCommentCard({
<Collapsible open={isOpen || !isLongContent} onOpenChange={setIsOpen}>
<div className={cn("text-sm whitespace-pre-wrap break-words leading-relaxed", !isOpen && isLongContent && "line-clamp-3")}>
{draft.text}
{draftText}
</div>
{isLongContent && (
@@ -29,9 +29,14 @@ export const buildPierreLineAnnotations = (
const annotations: DiffLineAnnotation<PierreAnnotationData>[] = [];
for (const draft of drafts) {
if (!Number.isFinite(draft.endLine)) {
continue;
}
const lineNumber = Math.max(1, Math.floor(draft.endLine));
const side: AnnotationSide = draft.side === 'original' ? 'deletions' : 'additions';
annotations.push({
lineNumber: draft.endLine,
lineNumber,
side,
metadata: {
type: draft.id === editingDraftId ? 'edit' : 'saved',
@@ -1,128 +0,0 @@
import React from 'react';
import { RiInformationLine, RiRestartLine } from '@remixicon/react';
import { NumberInput } from '@/components/ui/number-input';
import { ButtonSmall } from '@/components/ui/button-small';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { DEFAULT_MESSAGE_LIMIT } from '@/stores/types/sessionTypes';
const MIN_LIMIT = 10;
const MAX_LIMIT = 500;
export const MemoryLimitsSettings: React.FC = () => {
const messageLimit = useUIStore((state) => state.messageLimit);
const setMessageLimit = useUIStore((state) => state.setMessageLimit);
const [isLoading, setIsLoading] = React.useState(true);
// Load settings from server on mount
React.useEffect(() => {
const loadSettings = async () => {
try {
let data: { messageLimit?: number } | null = null;
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
const result = await runtimeSettings.load();
const settings = result?.settings as Record<string, unknown> | undefined;
if (settings) {
data = {
messageLimit: typeof settings.messageLimit === 'number' ? settings.messageLimit : undefined,
};
}
} catch {
// fall through
}
}
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
if (data && typeof data.messageLimit === 'number') {
setMessageLimit(data.messageLimit);
}
} catch (error) {
console.warn('Failed to load memory limits settings:', error);
} finally {
setIsLoading(false);
}
};
loadSettings();
}, [setMessageLimit]);
const handleChange = React.useCallback((value: number) => {
setMessageLimit(value);
void updateDesktopSettings({ messageLimit: value }).catch((error: unknown) => {
console.warn('Failed to save messageLimit:', error);
});
}, [setMessageLimit]);
if (isLoading) {
return null;
}
const isDefault = messageLimit === DEFAULT_MESSAGE_LIMIT;
return (
<div className="mb-8">
<div className="mb-1 px-1">
<div className="flex items-center gap-2">
<h3 className="typography-ui-header font-medium text-foreground">Message Memory</h3>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
Limit how many messages are loaded per session in memory.<br />
Older messages are available via "Load more". Background sessions are trimmed automatically.
</TooltipContent>
</Tooltip>
</div>
</div>
<section className="px-2 pb-2 pt-0">
<div className="flex flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-8">
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
<span className="typography-ui-label text-foreground">Message Limit</span>
</div>
<div className="flex items-center gap-2 sm:w-fit">
<NumberInput
value={messageLimit}
onValueChange={handleChange}
min={MIN_LIMIT}
max={MAX_LIMIT}
step={10}
aria-label="Message limit"
className="w-20 tabular-nums"
/>
<ButtonSmall
type="button"
variant="ghost"
onClick={() => handleChange(DEFAULT_MESSAGE_LIMIT)}
disabled={isDefault}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label="Reset message limit"
title="Reset"
>
<RiRestartLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
</div>
</section>
</div>
);
};
@@ -2,7 +2,6 @@ import React from 'react';
import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
import { AboutSettings } from './AboutSettings';
import { SessionRetentionSettings } from './SessionRetentionSettings';
import { MemoryLimitsSettings } from './MemoryLimitsSettings';
import { DefaultsSettings } from './DefaultsSettings';
import { GitSettings } from './GitSettings';
import { NotificationSettings } from './NotificationSettings';
@@ -115,12 +114,12 @@ const VisualSectionContent: React.FC = () => {
]} />;
};
// Chat section: Default Tool Output, User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Justification activity, Activity header timestamps, Queue mode, Persist draft
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Queue mode, Persist draft
const ChatSectionContent: React.FC = () => {
return <OpenChamberVisualSettings visibleSettings={['toolOutput', 'mermaidRendering', 'userMessageRendering', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'reasoning', 'textJustificationActivity', 'activityHeaderTimestamps', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
return <OpenChamberVisualSettings visibleSettings={['chatRenderMode', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'expandedTools', 'stickyUserHeader', 'diffLayout', 'mobileStatusBar', 'dotfiles', 'queueMode', 'persistDraft', 'inputSpellcheck']} />;
};
// Sessions section: Default model & agent, Session retention, Memory limits
// Sessions section: Default model & agent, Session retention
const SessionsSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime();
return (
@@ -134,9 +133,6 @@ const SessionsSectionContent: React.FC = () => {
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
<div className="border-t border-border/40 pt-6">
<MemoryLimitsSettings />
</div>
</div>
);
};
@@ -19,7 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
import { updateDesktopSettings } from '@/lib/persistence';
@@ -49,13 +49,6 @@ const THEME_MODE_OPTIONS: Array<{ value: ThemeMode; label: string }> = [
},
];
const TOOL_EXPANSION_OPTIONS: Array<{ value: 'collapsed' | 'activity' | 'detailed' | 'changes'; label: string; description: string }> = [
{ value: 'collapsed', label: 'Collapsed', description: 'Activity and tool calls stay collapsed by default.' },
{ value: 'activity', label: 'Summary', description: 'Activity opens by default; tool calls stay collapsed.' },
{ value: 'detailed', label: 'Detailed', description: 'Activity opens; key tools auto-expand for richer detail.' },
{ value: 'changes', label: 'Changes', description: 'Activity opens; only edit/write/patch tools auto-expand.' },
];
const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [
{
id: 'dynamic',
@@ -120,11 +113,37 @@ const USER_MESSAGE_RENDERING_OPTIONS: Option<'markdown' | 'plain'>[] = [
},
];
const CHAT_RENDER_MODE_OPTIONS: Option<'sorted' | 'live'>[] = [
{
id: 'sorted',
label: 'Sorted',
description: 'Render completed assistant messages without live streaming.',
},
{
id: 'live',
label: 'Live',
description: 'Stream assistant text and tools as they arrive.',
},
];
const ACTIVITY_RENDER_MODE_OPTIONS: Option<'collapsed' | 'summary'>[] = [
{
id: 'collapsed',
label: 'Collapsed',
description: 'Keep Activity collapsed by default.',
},
{
id: 'summary',
label: 'Summary',
description: 'Expand Activity by default.',
},
];
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'toolOutput' | 'mermaidRendering' | 'userMessageRendering' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'queueMode' | 'textJustificationActivity' | 'activityHeaderTimestamps' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'cornerRadius' | 'inputBarOffset' | 'navRail' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'activityRenderMode' | 'stickyUserHeader' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -137,18 +156,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const directoryShowHidden = useDirectoryShowHidden();
const showReasoningTraces = useUIStore(state => state.showReasoningTraces);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const showTextJustificationActivity = useUIStore(state => state.showTextJustificationActivity);
const setShowTextJustificationActivity = useUIStore(state => state.setShowTextJustificationActivity);
const showActivityHeaderTimestamps = useUIStore(state => state.showActivityHeaderTimestamps);
const setShowActivityHeaderTimestamps = useUIStore(state => state.setShowActivityHeaderTimestamps);
const toolCallExpansion = useUIStore(state => state.toolCallExpansion);
const setToolCallExpansion = useUIStore(state => state.setToolCallExpansion);
const mermaidRenderingMode = useUIStore(state => state.mermaidRenderingMode);
const setMermaidRenderingMode = useUIStore(state => state.setMermaidRenderingMode);
const userMessageRenderingMode = useUIStore(state => state.userMessageRenderingMode);
const setUserMessageRenderingMode = useUIStore(state => state.setUserMessageRenderingMode);
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
const chatRenderMode = useUIStore(state => state.chatRenderMode);
const setChatRenderMode = useUIStore(state => state.setChatRenderMode);
const activityRenderMode = useUIStore(state => state.activityRenderMode);
const setActivityRenderMode = useUIStore(state => state.setActivityRenderMode);
const fontSize = useUIStore(state => state.fontSize);
const setFontSize = useUIStore(state => state.setFontSize);
const terminalFontSize = useUIStore(state => state.terminalFontSize);
@@ -171,6 +189,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled);
const showToolFileIcons = useUIStore(state => state.showToolFileIcons);
const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons);
const showExpandedBashTools = useUIStore(state => state.showExpandedBashTools);
const setShowExpandedBashTools = useUIStore(state => state.setShowExpandedBashTools);
const showExpandedEditTools = useUIStore(state => state.showExpandedEditTools);
const setShowExpandedEditTools = useUIStore(state => state.setShowExpandedEditTools);
const isNavRailExpanded = useUIStore(state => state.isNavRailExpanded);
const setNavRailExpanded = useUIStore(state => state.setNavRailExpanded);
const showMobileSessionStatusBar = useUIStore(state => state.showMobileSessionStatusBar);
@@ -188,6 +212,18 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
} = useThemeSystem();
const [themesReloading, setThemesReloading] = React.useState(false);
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
React.useEffect(() => {
const intervalId = setInterval(() => {
setChatRenderPreviewTick((prev) => (prev + 1) % 24);
}, 420);
return () => {
clearInterval(intervalId);
};
}, []);
const handleUserMessageRenderingModeChange = React.useCallback((mode: 'markdown' | 'plain') => {
setUserMessageRenderingMode(mode);
void updateDesktopSettings({ userMessageRenderingMode: mode });
@@ -203,6 +239,36 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ inputSpellcheckEnabled: enabled });
}, [setInputSpellcheckEnabled]);
const handleChatRenderModeChange = React.useCallback((mode: 'sorted' | 'live') => {
setChatRenderMode(mode);
void updateDesktopSettings({ chatRenderMode: mode });
}, [setChatRenderMode]);
const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => {
setActivityRenderMode(mode);
void updateDesktopSettings({ activityRenderMode: mode });
}, [setActivityRenderMode]);
const handleMermaidRenderingModeChange = React.useCallback((mode: 'svg' | 'ascii') => {
setMermaidRenderingMode(mode);
void updateDesktopSettings({ mermaidRenderingMode: mode });
}, [setMermaidRenderingMode]);
const handleShowToolFileIconsChange = React.useCallback((enabled: boolean) => {
setShowToolFileIcons(enabled);
void updateDesktopSettings({ showToolFileIcons: enabled });
}, [setShowToolFileIcons]);
const handleShowExpandedBashToolsChange = React.useCallback((enabled: boolean) => {
setShowExpandedBashTools(enabled);
void updateDesktopSettings({ showExpandedBashTools: enabled });
}, [setShowExpandedBashTools]);
const handleShowExpandedEditToolsChange = React.useCallback((enabled: boolean) => {
setShowExpandedEditTools(enabled);
void updateDesktopSettings({ showExpandedEditTools: enabled });
}, [setShowExpandedEditTools]);
const lightThemes = React.useMemo(
() => availableThemes
.filter((theme) => theme.metadata.variant === 'light')
@@ -241,22 +307,22 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasAppearanceSettings = (shouldShow('theme') || shouldShow('pwaInstallName')) && !isVSCode;
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('spacing') || shouldShow('cornerRadius') || shouldShow('inputBarOffset');
const hasNavigationSettings = (!isMobile && shouldShow('navRail')) || (shouldShow('terminalQuickKeys') && !isMobile);
const hasBehaviorSettings = shouldShow('toolOutput')
|| shouldShow('mermaidRendering')
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| shouldShow('userMessageRendering')
|| shouldShow('chatRenderMode')
|| (shouldShow('activityRenderMode') && chatRenderMode === 'sorted')
|| shouldShow('stickyUserHeader')
|| shouldShow('diffLayout')
|| (shouldShow('mobileStatusBar') && isMobile)
|| shouldShow('dotfiles')
|| shouldShow('reasoning')
|| shouldShow('queueMode')
|| shouldShow('textJustificationActivity')
|| shouldShow('activityHeaderTimestamps')
|| shouldShow('persistDraft')
|| shouldShow('showToolFileIcons')
|| shouldShow('expandedTools')
|| (!isMobile && shouldShow('inputSpellcheck'));
const selectedToolExpansionOption = TOOL_EXPANSION_OPTIONS.find((option) => option.value === toolCallExpansion);
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab;
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
const [pwaInstallName, setPwaInstallName] = React.useState('');
const applyPwaInstallName = React.useCallback(async (value: string) => {
@@ -426,12 +492,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
{showPwaInstallNameSetting && (
<div className={cn('py-1.5', isMobile ? 'space-y-2' : 'flex items-center gap-8')}>
<div className={cn('flex min-w-0 flex-col', isMobile ? 'w-full' : 'w-56 shrink-0')}>
<div className="py-1.5 space-y-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">Install App Name</span>
<span className="typography-meta text-muted-foreground">Used by PWA installation process.</span>
</div>
<div className={cn('flex items-center gap-2', isMobile ? 'w-full' : 'w-fit min-w-[22rem]')}>
<div className="flex w-full max-w-[28rem] items-center gap-2">
<Input
value={pwaInstallName}
onChange={(event) => {
@@ -715,44 +781,182 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{hasBehaviorSettings && (
<div className="space-y-3">
{shouldShow('toolOutput') && (
<section className="px-2 pb-2 pt-0">
<h4 className="typography-ui-header font-medium text-foreground">Default Tool Output</h4>
<div className="mt-1.5 flex flex-wrap items-center gap-1">
{TOOL_EXPANSION_OPTIONS.map((option) => {
return (
<ButtonSmall
key={option.value}
type="button"
variant="outline"
size="xs"
className={cn(
'!font-normal',
toolCallExpansion === option.value
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
onClick={() => setToolCallExpansion(option.value)}
>
{option.label}
</ButtonSmall>
);
})}
</div>
{selectedToolExpansionOption && (
<p className="mt-2 typography-ui-label font-normal text-muted-foreground">
{selectedToolExpansionOption.description}
</p>
)}
</section>
)}
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || (shouldShow('diffLayout') && !isVSCode)) && (
{(shouldShow('userMessageRendering') || shouldShow('mermaidRendering') || shouldShow('chatRenderMode') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted') || (shouldShow('diffLayout') && !isVSCode)) && (
<div className="grid grid-cols-1 gap-y-2 md:grid-cols-[minmax(0,16rem)_minmax(0,16rem)] md:justify-start md:gap-x-2">
{shouldShow('chatRenderMode') && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Chat Render Mode</h4>
<div role="radiogroup" aria-label="Chat render mode" className="mt-1 grid w-full max-w-[26rem] grid-cols-1 gap-3 sm:grid-cols-2">
{CHAT_RENDER_MODE_OPTIONS.map((option) => {
const selected = chatRenderMode === option.id;
const previewPhase = chatRenderPreviewTick % 12;
return (
<button
key={option.id}
type="button"
onClick={() => handleChatRenderModeChange(option.id)}
aria-pressed={selected}
className={cn(
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors',
selected
? 'border-primary bg-primary/5'
: 'border-border hover:border-border/80 hover:bg-muted/50'
)}
>
<span className={cn('typography-ui-label', selected ? 'text-foreground' : 'text-muted-foreground')}>
{option.label}
</span>
<div className="mt-2 w-full rounded-md border border-border/60 bg-muted/30 p-2">
{option.id === 'live' ? (
<div className="space-y-1.5">
{[0, 1, 2].map((index) => {
const rowStart = index * 3 + 1;
const rowProgressPhase = previewPhase - rowStart + 1;
const rowProgress = rowProgressPhase <= 0
? 0
: rowProgressPhase === 1
? 42
: rowProgressPhase === 2
? 68
: 92;
const visible = rowProgress > 0;
return (
<div
key={index}
className={cn(
'flex items-center gap-1.5 transition-all duration-300 motion-reduce:transition-none',
visible ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
)}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-muted-foreground/55" />
<span
className="h-1.5 rounded bg-muted-foreground/30 transition-all duration-300 motion-reduce:transition-none"
style={{ width: `${rowProgress}%` }}
/>
</div>
);
})}
</div>
) : (
<div className="space-y-1.5">
{[0, 1, 2].map((index) => {
const visible = previewPhase >= (index + 1) * 3;
return (
<div
key={index}
className={cn(
'flex items-center gap-1.5 transition-all duration-300 motion-reduce:transition-none',
visible ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
)}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-muted-foreground/55" />
<span
className="h-1.5 rounded bg-muted-foreground/30"
style={{ width: '92%' }}
/>
</div>
);
})}
</div>
)}
</div>
</button>
);
})}
</div>
</section>
)}
{shouldShow('activityRenderMode') && chatRenderMode === 'sorted' && (
<section className="p-2 md:col-span-2">
<h4 className="typography-ui-header font-medium text-foreground">Activity Default</h4>
<div role="radiogroup" aria-label="Activity default mode" className="mt-0.5 space-y-0">
{ACTIVITY_RENDER_MODE_OPTIONS.map((option) => {
const selected = activityRenderMode === option.id;
return (
<div
key={option.id}
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={() => handleActivityRenderModeChange(option.id)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleActivityRenderModeChange(option.id);
}
}}
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
onChange={() => handleActivityRenderModeChange(option.id)}
ariaLabel={`Activity default mode: ${option.label}`}
/>
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
{option.label}
</span>
</div>
);
})}
</div>
</section>
)}
{shouldShow('expandedTools') && (
<section className="p-2 md:col-span-2 space-y-0.5">
<div className="typography-ui-header font-medium text-foreground py-1.5">Show tools opened by default:</div>
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showExpandedBashTools}
onClick={() => handleShowExpandedBashToolsChange(!showExpandedBashTools)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleShowExpandedBashToolsChange(!showExpandedBashTools);
}
}}
>
<Checkbox
checked={showExpandedBashTools}
onChange={handleShowExpandedBashToolsChange}
ariaLabel="Show expanded bash tools"
/>
<span className="typography-ui-label text-foreground">Bash</span>
</div>
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showExpandedEditTools}
onClick={() => handleShowExpandedEditToolsChange(!showExpandedEditTools)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleShowExpandedEditToolsChange(!showExpandedEditTools);
}
}}
>
<Checkbox
checked={showExpandedEditTools}
onChange={handleShowExpandedEditToolsChange}
ariaLabel="Show expanded edit tools"
/>
<span className="typography-ui-label text-foreground">Edit tools</span>
</div>
</section>
)}
{shouldShow('userMessageRendering') && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">User Message Rendering</h4>
<div role="radiogroup" aria-label="User message rendering mode" className="mt-1 space-y-0">
<div role="radiogroup" aria-label="User message rendering mode" className="mt-0.5 space-y-0">
{USER_MESSAGE_RENDERING_OPTIONS.map((option) => {
const selected = normalizeUserMessageRenderingMode(userMessageRenderingMode) === option.id;
return (
@@ -768,7 +972,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
handleUserMessageRenderingModeChange(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
@@ -788,7 +992,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('mermaidRendering') && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">Mermaid Rendering</h4>
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-1 space-y-0">
<div role="radiogroup" aria-label="Mermaid rendering mode" className="mt-0.5 space-y-0">
{MERMAID_RENDERING_OPTIONS.map((option) => {
const selected = mermaidRenderingMode === option.id;
return (
@@ -797,18 +1001,18 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={() => setMermaidRenderingMode(option.id)}
onClick={() => handleMermaidRenderingModeChange(option.id)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setMermaidRenderingMode(option.id);
handleMermaidRenderingModeChange(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
onChange={() => setMermaidRenderingMode(option.id)}
onChange={() => handleMermaidRenderingModeChange(option.id)}
ariaLabel={`Mermaid rendering: ${option.label}`}
/>
<span className={cn('typography-ui-label font-normal', selected ? 'text-foreground' : 'text-foreground/50')}>
@@ -824,7 +1028,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('diffLayout') && !isVSCode && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">Diff Layout</h4>
<div role="radiogroup" aria-label="Diff layout" className="mt-1 space-y-0">
<div role="radiogroup" aria-label="Diff layout" className="mt-0.5 space-y-0">
{DIFF_LAYOUT_OPTIONS.map((option) => {
const selected = diffLayoutPreference === option.id;
return (
@@ -840,7 +1044,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
setDiffLayoutPreference(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
@@ -860,7 +1064,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('diffLayout') && !isVSCode && (
<section className="p-2">
<h4 className="typography-ui-header font-medium text-foreground">Diff View Mode</h4>
<div role="radiogroup" aria-label="Diff view mode" className="mt-1 space-y-0">
<div role="radiogroup" aria-label="Diff view mode" className="mt-0.5 space-y-0">
{DIFF_VIEW_MODE_OPTIONS.map((option) => {
const selected = diffViewMode === option.id;
return (
@@ -876,7 +1080,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
setDiffViewMode(option.id);
}
}}
className="flex w-full items-center gap-2 py-0.5 text-left"
className="flex w-full items-center gap-2 py-0 text-left"
>
<Radio
checked={selected}
@@ -895,11 +1099,34 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning') || shouldShow('textJustificationActivity')) && (
{(shouldShow('stickyUserHeader') || (shouldShow('mobileStatusBar') && isMobile) || shouldShow('dotfiles') || shouldShow('queueMode') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
<section className="p-2 space-y-0.5">
{shouldShow('reasoning') && (
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showReasoningTraces}
onClick={() => setShowReasoningTraces(!showReasoningTraces)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowReasoningTraces(!showReasoningTraces);
}
}}
>
<Checkbox
checked={showReasoningTraces}
onChange={setShowReasoningTraces}
ariaLabel="Show reasoning traces"
/>
<span className="typography-ui-label text-foreground">Show Reasoning Traces</span>
</div>
)}
{shouldShow('stickyUserHeader') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={stickyUserHeader}
@@ -920,9 +1147,32 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{shouldShow('showToolFileIcons') && (
<div
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showToolFileIcons}
onClick={() => handleShowToolFileIconsChange(!showToolFileIcons)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleShowToolFileIconsChange(!showToolFileIcons);
}
}}
>
<Checkbox
checked={showToolFileIcons}
onChange={handleShowToolFileIconsChange}
ariaLabel="Show tool file icons"
/>
<span className="typography-ui-label text-foreground">Show Tool File Icons</span>
</div>
)}
{shouldShow('mobileStatusBar') && isMobile && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={showMobileSessionStatusBar}
@@ -945,7 +1195,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('dotfiles') && !isVSCodeRuntime() && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={directoryShowHidden}
@@ -968,7 +1218,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('queueMode') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={queueModeEnabled}
@@ -1001,7 +1251,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{shouldShow('persistDraft') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
className="group flex cursor-pointer items-center gap-2 py-0.5"
role="button"
tabIndex={0}
aria-pressed={persistChatDraft}
@@ -1045,74 +1295,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
)}
{shouldShow('reasoning') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showReasoningTraces}
onClick={() => setShowReasoningTraces(!showReasoningTraces)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowReasoningTraces(!showReasoningTraces);
}
}}
>
<Checkbox
checked={showReasoningTraces}
onChange={setShowReasoningTraces}
ariaLabel="Show reasoning traces"
/>
<span className="typography-ui-label text-foreground">Show Reasoning Traces</span>
</div>
)}
{shouldShow('textJustificationActivity') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showTextJustificationActivity}
onClick={() => setShowTextJustificationActivity(!showTextJustificationActivity)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowTextJustificationActivity(!showTextJustificationActivity);
}
}}
>
<Checkbox
checked={showTextJustificationActivity}
onChange={setShowTextJustificationActivity}
ariaLabel="Show justification activity"
/>
<span className="typography-ui-label text-foreground">Show Justification Activity</span>
</div>
)}
{shouldShow('activityHeaderTimestamps') && (
<div
className="group flex cursor-pointer items-center gap-2 py-1.5"
role="button"
tabIndex={0}
aria-pressed={showActivityHeaderTimestamps}
onClick={() => setShowActivityHeaderTimestamps(!showActivityHeaderTimestamps)}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
setShowActivityHeaderTimestamps(!showActivityHeaderTimestamps);
}
}}
>
<Checkbox
checked={showActivityHeaderTimestamps}
onChange={setShowActivityHeaderTimestamps}
ariaLabel="Show tool and reasoning header timestamps"
/>
<span className="typography-ui-label text-foreground">Show Activity Header Timestamps</span>
</div>
)}
</section>
)}
@@ -319,6 +319,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
worktreeMetadata,
pinnedSessionIds,
gitDirectories,
isVSCode,
});
const { scheduleCollapsedProjectsPersist } = useSidebarPersistence({
@@ -34,6 +34,7 @@ import {
RiUnpinLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isVSCodeRuntime } from '@/lib/desktop';
import { DraggableSessionRow } from './sessionFolderDnd';
import type { SessionNode, SessionSummaryMeta } from './types';
import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
@@ -140,6 +141,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
const displayMode = useSessionDisplayStore((state) => state.displayMode);
const isMinimalMode = displayMode === 'minimal';
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const session = node.session;
const sessionDirectory =
@@ -463,22 +465,24 @@ export function SessionNodeItem(props: Props): React.ReactNode {
);
})() : null}
<DropdownMenuItem
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
openContextPanelTab(sessionDirectory, {
mode: 'chat',
dedupeKey: `session:${session.id}`,
label: sessionTitle,
});
}}
className="[&>svg]:mr-1"
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
</DropdownMenuItem>
{!isVSCode ? (
<DropdownMenuItem
disabled={!sessionDirectory}
onClick={() => {
if (!sessionDirectory) return;
openContextPanelTab(sessionDirectory, {
mode: 'chat',
dedupeKey: `session:${session.id}`,
label: sessionTitle,
});
}}
className="[&>svg]:mr-1"
>
<RiChat4Line className="mr-1 h-4 w-4" />
<span className="truncate">Open in Side Panel</span>
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
@@ -85,16 +85,29 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
if (!activeSection) {
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
}
const group =
const primaryGroup =
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
?? activeSection.groups.find((candidate) => candidate.isMain)
?? activeSection.groups[0];
if (!group) {
if (!primaryGroup) {
return <div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>;
}
const groupKey = `${activeSection.project.id}:${group.id}`;
return props.renderGroupSessions(group, groupKey, activeSection.project.id, props.showOnlyMainWorkspace);
const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
const groupsToRender = [
primaryGroup,
...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []),
];
return groupsToRender.map((group) => {
const groupKey = `${activeSection.project.id}:${group.id}`;
const hideGroupLabel = group.id === primaryGroup.id;
return (
<React.Fragment key={groupKey}>
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel)}
</React.Fragment>
);
});
})()}
</div>
) : (
@@ -54,6 +54,33 @@ export const useProjectSessionLists = (args: Args) => {
const getArchivedSessionsForProject = React.useCallback(
(project: { normalizedPath: string }) => {
if (isVSCode) {
const archived = archivedSessions.filter((session) => {
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
if (sessionDirectory) {
return sessionDirectory === project.normalizedPath;
}
return projectWorktree === project.normalizedPath;
});
const unassignedLive = sessions.filter((session) => {
if (session.time?.archived) {
return false;
}
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
if (sessionDirectory) {
return false;
}
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
return projectWorktree === project.normalizedPath;
});
return dedupeSessionsById([...archived, ...unassignedLive]);
}
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
const validDirectories = new Set<string>([
project.normalizedPath,
@@ -16,6 +16,7 @@ type Args = {
worktreeMetadata: Map<string, WorktreeMetadata>;
pinnedSessionIds: Set<string>;
gitDirectories: Map<string, { status?: { current?: string | null } | null }>;
isVSCode: boolean;
};
export const useSessionGrouping = (args: Args) => {
@@ -219,13 +220,13 @@ export const useSessionGrouping = (args: Args) => {
isArchivedBucket: true,
worktree: null,
directory: null,
folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
folderScopeKey: !args.isVSCode && normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
sessions: groupedNodes.get(archivedKey) ?? [],
});
return groups;
},
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories],
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories, args.isVSCode],
);
return {
@@ -34,8 +34,8 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa
}
const hasMessages = state.messages.has(nextSessionId);
const memory = state.sessionMemoryState.get(nextSessionId);
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
const historyMeta = state.sessionHistoryMeta.get(nextSessionId);
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
if (isHydrated) {
continue;
}
@@ -57,8 +57,8 @@ export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessa
const state = useSessionStore.getState();
const hasMessages = state.messages.has(sessionId);
const memory = state.sessionMemoryState.get(sessionId);
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
const historyMeta = state.sessionHistoryMeta.get(sessionId);
const isHydrated = hasMessages && typeof historyMeta?.complete === 'boolean';
if (isHydrated) {
return;
}
@@ -5,7 +5,7 @@ import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionT
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react';
import { RiCloseLine, RiDatabase2Line, RiPulseLine } from '@remixicon/react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface MemoryDebugPanelProps {
@@ -18,8 +18,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
messages,
sessionMemoryState,
currentSessionId,
trimToViewportWindow,
evictLeastRecentlyUsed
} = useSessionStore();
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
@@ -90,10 +88,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
<span className="text-muted-foreground">Viewport Window:</span>
<span>{getBackgroundTrimLimit()} messages</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Background Stream Limit:</span>
<span>{MEMORY_LIMITS.BACKGROUND_STREAMING_BUFFER} messages</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Zombie Timeout:</span>
<span>{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
@@ -139,45 +133,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
</ScrollableOverlay>
</div>
{}
<div className="flex gap-2 pt-2 border-t">
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
if (currentSessionId) {
trimToViewportWindow(currentSessionId, 10);
}
}}
>
<RiDeleteBinLine className="h-3 w-3 mr-1" />
Force Trim (10)
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Trim current session to only 10 most recent messages
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
evictLeastRecentlyUsed();
}}
>
Evict LRU
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Remove least recently used sessions from memory cache
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
@@ -8,6 +8,8 @@ type OverlayScrollbarProps = {
className?: string;
disableHorizontal?: boolean;
observeMutations?: boolean;
suppressVisibility?: boolean;
userIntentOnly?: boolean;
};
type ThumbMetrics = {
@@ -15,6 +17,8 @@ type ThumbMetrics = {
offset: number;
};
const USER_SCROLL_INTENT_WINDOW_MS = 1000;
export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
containerRef,
minThumbSize = 32,
@@ -22,6 +26,8 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
className,
disableHorizontal = false,
observeMutations = true,
suppressVisibility = false,
userIntentOnly = false,
}) => {
const [visible, setVisible] = React.useState(false);
const [vertical, setVertical] = React.useState<ThumbMetrics>({ length: 0, offset: 0 });
@@ -29,6 +35,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
const hideTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const frameRef = React.useRef<number | null>(null);
const isDraggingRef = React.useRef(false);
const lastUserIntentAtRef = React.useRef(0);
const dragStartRef = React.useRef<{
pointerX: number;
pointerY: number;
@@ -76,26 +83,61 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
}, [hideDelayMs]);
const markUserIntent = React.useCallback(() => {
lastUserIntentAtRef.current = Date.now();
}, []);
const handleScroll = React.useCallback(() => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
frameRef.current = requestAnimationFrame(() => {
updateMetrics();
if (suppressVisibility && !isDraggingRef.current) {
setVisible(false);
return;
}
if (userIntentOnly && !isDraggingRef.current) {
const hasRecentUserIntent = Date.now() - lastUserIntentAtRef.current <= USER_SCROLL_INTENT_WINDOW_MS;
if (!hasRecentUserIntent) {
setVisible(false);
return;
}
}
setVisible(true);
scheduleHide();
});
}, [scheduleHide, updateMetrics]);
}, [scheduleHide, suppressVisibility, updateMetrics, userIntentOnly]);
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
updateMetrics();
setVisible(true);
scheduleHide();
setVisible(false);
container.addEventListener("scroll", handleScroll, { passive: true });
const onScroll = () => handleScroll();
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key === 'ArrowUp'
|| event.key === 'ArrowDown'
|| event.key === 'PageUp'
|| event.key === 'PageDown'
|| event.key === 'Home'
|| event.key === 'End'
|| event.key === ' '
) {
markUserIntent();
}
};
container.addEventListener("scroll", onScroll, { passive: true });
if (userIntentOnly) {
container.addEventListener("wheel", markUserIntent, { passive: true });
container.addEventListener("touchstart", markUserIntent, { passive: true });
container.addEventListener("touchmove", markUserIntent, { passive: true });
container.addEventListener("keydown", onKeyDown);
}
const resizeObserver =
typeof ResizeObserver !== "undefined"
@@ -110,13 +152,33 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
mutationObserver?.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
container.removeEventListener("scroll", handleScroll);
container.removeEventListener("scroll", onScroll);
if (userIntentOnly) {
container.removeEventListener("wheel", markUserIntent);
container.removeEventListener("touchstart", markUserIntent);
container.removeEventListener("touchmove", markUserIntent);
container.removeEventListener("keydown", onKeyDown);
}
resizeObserver?.disconnect();
mutationObserver?.disconnect();
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (frameRef.current) cancelAnimationFrame(frameRef.current);
};
}, [containerRef, handleScroll, observeMutations, scheduleHide, updateMetrics]);
}, [containerRef, handleScroll, markUserIntent, observeMutations, updateMetrics, userIntentOnly]);
React.useEffect(() => {
if (!suppressVisibility) {
return;
}
if (isDraggingRef.current) {
return;
}
setVisible(false);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, [suppressVisibility]);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>, axis: "vertical" | "horizontal") => {
const container = containerRef.current;
@@ -130,6 +192,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
scrollLeft: container.scrollLeft,
};
dragAxisRef.current = axis;
markUserIntent();
setVisible(true);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
event.currentTarget.setPointerCapture(event.pointerId);
+26 -13
View File
@@ -35,36 +35,49 @@ const variants = [
{
variant: "generate-effect",
component: ({ children, className, ...props }) => {
if (typeof children !== "string") return null;
if (children === null || typeof children === "undefined") return null;
const textContent =
typeof children === "string"
? children
: typeof children === "number"
? String(children)
: Array.isArray(children)
? children
.map((item) =>
typeof item === "string" || typeof item === "number"
? String(item)
: ""
)
.join("")
: "";
if (!textContent) return null;
return (
<div className="inline-block whitespace-pre">
{children.split("").map((char, index) => (
<span className={cn("inline-block align-baseline", className)}>
{textContent.split("").map((char, index) => (
<motion.span
{...props}
key={char + String(index)}
className={cn(
"inline-block whitespace-pre text-primary-foreground",
className
"inline-block whitespace-pre align-baseline"
)}
initial={{ opacity: 0, filter: "blur(4px)", rotateX: 90, y: 5 }}
whileInView={{
initial={{ opacity: 0, filter: "blur(3px)" }}
animate={{
opacity: 1,
filter: "blur(0px)",
rotateX: 0,
y: 0,
}}
transition={{
ease: "easeOut",
duration: 0.3,
delay: index * 0.015,
duration: 0.14,
delay: Math.min(index * 0.0045, 0.14),
}}
viewport={{ once: true }}
>
{char}
</motion.span>
))}
</div>
</span>
);
},
},
@@ -546,7 +546,17 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const instance = diffInstanceRef.current;
if (!instance) return;
instance.setLineAnnotations(lineAnnotations);
try {
instance.setLineAnnotations(lineAnnotations);
} catch (error) {
console.error('Failed to apply diff line annotations', error);
try {
instance.setLineAnnotations([]);
} catch {
// ignored
}
}
requestAnimationFrame(() => {
if (diffInstanceRef.current !== instance) return;
try {