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:
committed by
GitHub
parent
d969ff4af1
commit
fab32ca992
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,6 +2,12 @@ import React from 'react';
|
||||
import type { Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
|
||||
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
|
||||
import {
|
||||
isNearBottom,
|
||||
normalizeWheelDelta,
|
||||
shouldPauseAutoScrollOnWheel,
|
||||
} from '@/components/chat/lib/scroll/scrollIntent';
|
||||
|
||||
import { useScrollEngine } from './useScrollEngine';
|
||||
|
||||
@@ -34,8 +40,9 @@ interface UseChatScrollManagerOptions {
|
||||
updateViewportAnchor: (sessionId: string, anchor: number) => void;
|
||||
isSyncing: boolean;
|
||||
isMobile: boolean;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
messageStreamStates: Map<string, unknown>;
|
||||
trimToViewportWindow: (sessionId: string, targetSize?: number) => void;
|
||||
onActiveTurnChange?: (turnId: string | null) => void;
|
||||
}
|
||||
|
||||
export interface AnimationHandlers {
|
||||
@@ -55,13 +62,19 @@ interface UseChatScrollManagerResult {
|
||||
showScrollButton: boolean;
|
||||
scrollToBottom: (options?: { instant?: boolean; force?: boolean }) => void;
|
||||
scrollToPosition: (position: number, options?: { instant?: boolean }) => void;
|
||||
releasePinnedScroll: () => void;
|
||||
isPinned: boolean;
|
||||
isOverflowing: boolean;
|
||||
isProgrammaticFollowActive: boolean;
|
||||
}
|
||||
|
||||
const PROGRAMMATIC_SCROLL_SUPPRESS_MS = 200;
|
||||
const DIRECT_SCROLL_INTENT_WINDOW_MS = 250;
|
||||
// Threshold for re-pinning: 10% of container height (matches bottom spacer)
|
||||
const PIN_THRESHOLD_RATIO = 0.10;
|
||||
const REPIN_BLOCK_AFTER_RELEASE_MS = 4000;
|
||||
const STRICT_REPIN_DISTANCE_PX = 160;
|
||||
const SORTED_PIN_THRESHOLD_PX = 24;
|
||||
|
||||
export const useChatScrollManager = ({
|
||||
currentSessionId,
|
||||
@@ -70,6 +83,8 @@ export const useChatScrollManager = ({
|
||||
updateViewportAnchor,
|
||||
isSyncing,
|
||||
isMobile,
|
||||
chatRenderMode = 'live',
|
||||
onActiveTurnChange,
|
||||
}: UseChatScrollManagerOptions): UseChatScrollManagerResult => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scrollEngine = useScrollEngine({ containerRef: scrollRef, isMobile });
|
||||
@@ -83,14 +98,24 @@ export const useChatScrollManager = ({
|
||||
return Math.max(24, Math.min(200, raw));
|
||||
}, []);
|
||||
|
||||
const getAutoFollowThreshold = React.useCallback(() => {
|
||||
if (chatRenderMode === 'sorted') {
|
||||
return SORTED_PIN_THRESHOLD_PX;
|
||||
}
|
||||
return getPinThreshold();
|
||||
}, [chatRenderMode, getPinThreshold]);
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = React.useState(false);
|
||||
const [isPinned, setIsPinned] = React.useState(true);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
const lastSessionIdRef = React.useRef<string | null>(null);
|
||||
const suppressUserScrollUntilRef = React.useRef<number>(0);
|
||||
const lastDirectScrollIntentAtRef = React.useRef<number>(0);
|
||||
const isPinnedRef = React.useRef(true);
|
||||
const repinBlockedUntilRef = React.useRef<number>(0);
|
||||
const lastScrollTopRef = React.useRef<number>(0);
|
||||
const touchLastYRef = React.useRef<number | null>(null);
|
||||
|
||||
const markProgrammaticScroll = React.useCallback(() => {
|
||||
suppressUserScrollUntilRef.current = Date.now() + PROGRAMMATIC_SCROLL_SUPPRESS_MS;
|
||||
@@ -102,6 +127,10 @@ export const useChatScrollManager = ({
|
||||
return container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
}, []);
|
||||
|
||||
const isStrictlyAtBottom = React.useCallback((distanceFromBottom: number) => {
|
||||
return distanceFromBottom <= STRICT_REPIN_DISTANCE_PX;
|
||||
}, []);
|
||||
|
||||
const updatePinnedState = React.useCallback((newPinned: boolean) => {
|
||||
if (isPinnedRef.current !== newPinned) {
|
||||
isPinnedRef.current = newPinned;
|
||||
@@ -119,6 +148,9 @@ export const useChatScrollManager = ({
|
||||
}, [markProgrammaticScroll, scrollEngine]);
|
||||
|
||||
const scrollPinnedToBottom = React.useCallback(() => {
|
||||
if (Date.now() < repinBlockedUntilRef.current) {
|
||||
return;
|
||||
}
|
||||
if (streamingMessageId) {
|
||||
scrollToBottomInternal({ followBottom: true });
|
||||
return;
|
||||
@@ -131,10 +163,12 @@ export const useChatScrollManager = ({
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
setShowScrollButton(false);
|
||||
setIsOverflowing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasScrollableContent = container.scrollHeight > container.clientHeight;
|
||||
setIsOverflowing(hasScrollableContent);
|
||||
if (!hasScrollableContent) {
|
||||
setShowScrollButton(false);
|
||||
return;
|
||||
@@ -142,7 +176,7 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Show scroll button when scrolled above the 10vh threshold
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
setShowScrollButton(distanceFromBottom > getPinThreshold());
|
||||
setShowScrollButton(!isNearBottom(distanceFromBottom, getPinThreshold()));
|
||||
}, [getDistanceFromBottom, getPinThreshold]);
|
||||
|
||||
const scrollToPosition = React.useCallback((position: number, options?: { instant?: boolean }) => {
|
||||
@@ -158,12 +192,20 @@ export const useChatScrollManager = ({
|
||||
if (!container) return;
|
||||
|
||||
// Re-pin when explicitly scrolling to bottom
|
||||
repinBlockedUntilRef.current = 0;
|
||||
updatePinnedState(true);
|
||||
|
||||
scrollToBottomInternal(options);
|
||||
setShowScrollButton(false);
|
||||
}, [scrollToBottomInternal, updatePinnedState]);
|
||||
|
||||
const releasePinnedScroll = React.useCallback(() => {
|
||||
scrollEngine.cancelFollow();
|
||||
repinBlockedUntilRef.current = Date.now() + REPIN_BLOCK_AFTER_RELEASE_MS;
|
||||
updatePinnedState(false);
|
||||
updateScrollButtonVisibility();
|
||||
}, [scrollEngine, updatePinnedState, updateScrollButtonVisibility]);
|
||||
|
||||
const handleScrollEvent = React.useCallback((event?: Event) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container || !currentSessionId) {
|
||||
@@ -179,19 +221,26 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Handle pin/unpin logic
|
||||
const currentScrollTop = container.scrollTop;
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
|
||||
// Unpin requires strict user intent check
|
||||
if (event?.isTrusted && !isProgrammatic && hasDirectIntent) {
|
||||
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||
if (scrollingUp && isPinnedRef.current) {
|
||||
const scrollingUp = currentScrollTop < lastScrollTopRef.current;
|
||||
|
||||
// Unpin whenever we move away from bottom.
|
||||
// Also handle programmatic jumps to older content (timeline navigation)
|
||||
// so we don't snap back to bottom on the next content update.
|
||||
if (isPinnedRef.current) {
|
||||
const nearBottom = isNearBottom(distanceFromBottom, getPinThreshold());
|
||||
const scrollingUpByUserIntent = Boolean(!isProgrammatic && event?.isTrusted && hasDirectIntent && scrollingUp);
|
||||
const programmaticJumpAwayFromBottom = Boolean(!event?.isTrusted && scrollingUp && !nearBottom);
|
||||
|
||||
if (scrollingUpByUserIntent || programmaticJumpAwayFromBottom) {
|
||||
updatePinnedState(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pin at bottom should always work (even momentum scroll)
|
||||
if (!isPinnedRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom <= getPinThreshold()) {
|
||||
// Re-pin only when returning to bottom, not while still scrolling up.
|
||||
if (!isPinnedRef.current && now >= repinBlockedUntilRef.current) {
|
||||
if (event?.isTrusted && !scrollingUp && isStrictlyAtBottom(distanceFromBottom)) {
|
||||
updatePinnedState(true);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +255,7 @@ export const useChatScrollManager = ({
|
||||
currentSessionId,
|
||||
getDistanceFromBottom,
|
||||
getPinThreshold,
|
||||
isStrictlyAtBottom,
|
||||
scrollEngine,
|
||||
sessionMessages.length,
|
||||
updatePinnedState,
|
||||
@@ -213,6 +263,31 @@ export const useChatScrollManager = ({
|
||||
updateViewportAnchor,
|
||||
]);
|
||||
|
||||
const handleWheelIntent = React.useCallback((event: WheelEvent) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
rootHeight: container.clientHeight,
|
||||
});
|
||||
|
||||
// Scrolling up while pinned → unpin and kill follow loop immediately
|
||||
if (isPinnedRef.current && shouldPauseAutoScrollOnWheel({
|
||||
root: container,
|
||||
target: event.target,
|
||||
delta,
|
||||
})) {
|
||||
scrollEngine.cancelFollow();
|
||||
updatePinnedState(false);
|
||||
return;
|
||||
}
|
||||
|
||||
}, [scrollEngine, updatePinnedState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
@@ -221,16 +296,69 @@ export const useChatScrollManager = ({
|
||||
lastDirectScrollIntentAtRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleTouchStartIntent = (event: TouchEvent) => {
|
||||
markDirectIntent();
|
||||
const touch = event.touches.item(0);
|
||||
touchLastYRef.current = touch ? touch.clientY : null;
|
||||
};
|
||||
|
||||
const handleTouchMoveIntent = (event: TouchEvent) => {
|
||||
markDirectIntent();
|
||||
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) {
|
||||
touchLastYRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousY = touchLastYRef.current;
|
||||
touchLastYRef.current = touch.clientY;
|
||||
if (previousY === null || !isPinnedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerDelta = touch.clientY - previousY;
|
||||
if (Math.abs(fingerDelta) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syntheticWheelDelta = -fingerDelta;
|
||||
if (syntheticWheelDelta >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldPauseAutoScrollOnWheel({
|
||||
root: container,
|
||||
target: event.target,
|
||||
delta: syntheticWheelDelta,
|
||||
})) {
|
||||
scrollEngine.cancelFollow();
|
||||
updatePinnedState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEndIntent = () => {
|
||||
touchLastYRef.current = null;
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScrollEvent as EventListener, { passive: true });
|
||||
container.addEventListener('touchstart', handleTouchStartIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchmove', handleTouchMoveIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchend', handleTouchEndIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchcancel', handleTouchEndIntent as EventListener, { passive: true });
|
||||
container.addEventListener('wheel', handleWheelIntent as EventListener, { passive: true });
|
||||
container.addEventListener('wheel', markDirectIntent as EventListener, { passive: true });
|
||||
container.addEventListener('touchmove', markDirectIntent as EventListener, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScrollEvent as EventListener);
|
||||
container.removeEventListener('touchstart', handleTouchStartIntent as EventListener);
|
||||
container.removeEventListener('touchmove', handleTouchMoveIntent as EventListener);
|
||||
container.removeEventListener('touchend', handleTouchEndIntent as EventListener);
|
||||
container.removeEventListener('touchcancel', handleTouchEndIntent as EventListener);
|
||||
container.removeEventListener('wheel', handleWheelIntent as EventListener);
|
||||
container.removeEventListener('wheel', markDirectIntent as EventListener);
|
||||
container.removeEventListener('touchmove', markDirectIntent as EventListener);
|
||||
};
|
||||
}, [handleScrollEvent]);
|
||||
}, [handleScrollEvent, handleWheelIntent, scrollEngine, updatePinnedState]);
|
||||
|
||||
// Session switch - always start pinned at bottom
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
@@ -255,6 +383,7 @@ export const useChatScrollManager = ({
|
||||
// Maintain pin-to-bottom when content changes
|
||||
React.useEffect(() => {
|
||||
if (!isPinnedRef.current) return;
|
||||
if (Date.now() < repinBlockedUntilRef.current) return;
|
||||
if (isSyncing) return;
|
||||
|
||||
const container = scrollRef.current;
|
||||
@@ -262,10 +391,10 @@ export const useChatScrollManager = ({
|
||||
|
||||
// When pinned and content grows, follow bottom with fast smooth scroll
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}, [getDistanceFromBottom, getPinThreshold, isSyncing, scrollPinnedToBottom, sessionMessages]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, isSyncing, scrollPinnedToBottom, sessionMessages]);
|
||||
|
||||
// Use ResizeObserver to detect content changes and maintain pin
|
||||
React.useEffect(() => {
|
||||
@@ -276,9 +405,9 @@ export const useChatScrollManager = ({
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content grows - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -288,9 +417,9 @@ export const useChatScrollManager = ({
|
||||
|
||||
// Also observe children for content changes
|
||||
const childObserver = new MutationObserver(() => {
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -302,7 +431,7 @@ export const useChatScrollManager = ({
|
||||
observer.disconnect();
|
||||
childObserver.disconnect();
|
||||
};
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -325,13 +454,13 @@ export const useChatScrollManager = ({
|
||||
updateScrollButtonVisibility();
|
||||
|
||||
// Maintain pin when content changes - fast smooth follow
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
|
||||
const existing = animationHandlersRef.current.get(messageId);
|
||||
@@ -342,9 +471,9 @@ export const useChatScrollManager = ({
|
||||
const handlers: AnimationHandlers = {
|
||||
onChunk: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -356,9 +485,9 @@ export const useChatScrollManager = ({
|
||||
onAnimationStart: () => {},
|
||||
onAnimatedHeightChange: () => {
|
||||
updateScrollButtonVisibility();
|
||||
if (isPinnedRef.current) {
|
||||
if (isPinnedRef.current && Date.now() >= repinBlockedUntilRef.current) {
|
||||
const distanceFromBottom = getDistanceFromBottom();
|
||||
if (distanceFromBottom > getPinThreshold()) {
|
||||
if (distanceFromBottom > getAutoFollowThreshold()) {
|
||||
scrollPinnedToBottom();
|
||||
}
|
||||
}
|
||||
@@ -369,7 +498,59 @@ export const useChatScrollManager = ({
|
||||
|
||||
animationHandlersRef.current.set(messageId, handlers);
|
||||
return handlers;
|
||||
}, [getDistanceFromBottom, getPinThreshold, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
}, [getAutoFollowThreshold, getDistanceFromBottom, scrollPinnedToBottom, updateScrollButtonVisibility]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onActiveTurnChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
onActiveTurnChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const spy = createScrollSpy({
|
||||
onActive: (turnId) => {
|
||||
onActiveTurnChange(turnId);
|
||||
},
|
||||
});
|
||||
|
||||
spy.setContainer(container);
|
||||
|
||||
const registerTurns = () => {
|
||||
spy.clear();
|
||||
const turnNodes = container.querySelectorAll<HTMLElement>('[data-turn-id]');
|
||||
turnNodes.forEach((node) => {
|
||||
const turnId = node.dataset.turnId;
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
spy.register(node, turnId);
|
||||
});
|
||||
spy.markDirty();
|
||||
};
|
||||
|
||||
registerTurns();
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
registerTurns();
|
||||
});
|
||||
mutationObserver.observe(container, { subtree: true, childList: true });
|
||||
|
||||
const handleScroll = () => {
|
||||
spy.onScroll();
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
mutationObserver.disconnect();
|
||||
spy.destroy();
|
||||
onActiveTurnChange(null);
|
||||
};
|
||||
}, [currentSessionId, onActiveTurnChange, scrollRef, sessionMessages.length]);
|
||||
|
||||
return {
|
||||
scrollRef,
|
||||
@@ -378,6 +559,9 @@ export const useChatScrollManager = ({
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
scrollToPosition,
|
||||
releasePinnedScroll,
|
||||
isPinned,
|
||||
isOverflowing,
|
||||
isProgrammaticFollowActive: scrollEngine.isFollowingBottom,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,48 +176,13 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const TEXT_SHRINK_TOLERANCE = 50;
|
||||
const RESYNC_DEBOUNCE_MS = 750;
|
||||
const QUESTION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
const PERMISSION_RECONCILE_COOLDOWN_MS = 1500;
|
||||
|
||||
const textLengthCache = new WeakMap<Part[], number>();
|
||||
const computeTextLength = (parts: Part[] | undefined | null): number => {
|
||||
if (!parts || !Array.isArray(parts)) return 0;
|
||||
|
||||
const cached = textLengthCache.get(parts);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let length = 0;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part?.type === 'text') {
|
||||
const text = (part as { text?: string; content?: string }).text ?? (part as { text?: string; content?: string }).content;
|
||||
if (typeof text === 'string') length += text.length;
|
||||
}
|
||||
}
|
||||
|
||||
textLengthCache.set(parts, length);
|
||||
return length;
|
||||
};
|
||||
|
||||
const MIN_SORTABLE_LENGTH = 10;
|
||||
const extractSortableId = (id: unknown): string | null => {
|
||||
if (typeof id !== 'string') return null;
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) return null;
|
||||
const underscoreIndex = trimmed.indexOf('_');
|
||||
const candidate = underscoreIndex >= 0 ? trimmed.slice(underscoreIndex + 1) : trimmed;
|
||||
if (!candidate || candidate.length < MIN_SORTABLE_LENGTH) return null;
|
||||
return candidate;
|
||||
};
|
||||
|
||||
const isIdNewer = (id: string, referenceId: string): boolean => {
|
||||
const currentSortable = extractSortableId(id);
|
||||
const referenceSortable = extractSortableId(referenceId);
|
||||
if (!currentSortable || !referenceSortable) return true;
|
||||
if (currentSortable.length !== referenceSortable.length) return true;
|
||||
return currentSortable > referenceSortable;
|
||||
const readEventDirectory = (props: Record<string, unknown>): string => {
|
||||
const directory = readStringProp(props, ['directory']);
|
||||
return directory ?? 'global';
|
||||
};
|
||||
|
||||
const MAX_MESSAGE_CACHE_SIZE = 500;
|
||||
@@ -247,10 +212,17 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
|
||||
return message;
|
||||
};
|
||||
|
||||
const getLatestMessageFromStore = (sessionId: string, messageId: string): { info: Message; parts: Part[] } | null => {
|
||||
const storeState = useSessionStore.getState();
|
||||
const sessionMessages = storeState.messages.get(sessionId) || [];
|
||||
return sessionMessages.find((message) => message.info.id === messageId) || null;
|
||||
};
|
||||
|
||||
export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const {
|
||||
addStreamingPart,
|
||||
applyPartDelta,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
updateSessionCompaction,
|
||||
@@ -557,12 +529,27 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const lastMessageEventBySessionRef = React.useRef<Map<string, number>>(new Map());
|
||||
const pendingMessageStallTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const lastMessageStallRecoveryBySessionRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
const partTypeHintsByKeyRef = React.useRef<Map<string, string>>(new Map());
|
||||
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
|
||||
const sessionActivityLastRefreshAtRef = React.useRef<number>(0);
|
||||
const sessionActivityRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
|
||||
const scheduleSoftResyncRef = React.useRef<
|
||||
(sessionId: string, reason: string, limit?: number) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
|
||||
|
||||
const writePartTypeHint = React.useCallback((key: string, type: string) => {
|
||||
const map = partTypeHintsByKeyRef.current;
|
||||
map.set(key, type);
|
||||
if (map.size > 4000) {
|
||||
const firstKey = map.keys().next().value;
|
||||
if (typeof firstKey === 'string') {
|
||||
map.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isNotificationContextHidden = React.useCallback((isVSCodeRuntime: boolean): boolean => {
|
||||
if (visibilityStateRef.current === 'hidden') {
|
||||
return true;
|
||||
@@ -789,24 +776,161 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
useSessionStore.setState({ sessionStatus: next });
|
||||
}, []);
|
||||
|
||||
const updateSessionActivityPhase = React.useCallback((
|
||||
sessionId: string,
|
||||
phase: 'idle' | 'busy' | 'cooldown',
|
||||
source: string = 'unknown',
|
||||
options?: { syncStatus?: boolean }
|
||||
) => {
|
||||
if (!sessionId) return;
|
||||
const syncStatus = options?.syncStatus !== false;
|
||||
|
||||
const current = sessionActivityPhaseRef.current.get(sessionId);
|
||||
if (current === phase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const next = new Map(sessionActivityPhaseRef.current);
|
||||
next.set(sessionId, phase);
|
||||
sessionActivityPhaseRef.current = next;
|
||||
|
||||
if (!syncStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'idle') {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, `${source}:idle`);
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, `${source}:${phase}`);
|
||||
|
||||
if (phase === 'cooldown') {
|
||||
const timer = setTimeout(() => {
|
||||
sessionCooldownTimersRef.current.delete(sessionId);
|
||||
if (sessionActivityPhaseRef.current.get(sessionId) !== 'cooldown') {
|
||||
return;
|
||||
}
|
||||
const latest = new Map(sessionActivityPhaseRef.current);
|
||||
latest.set(sessionId, 'idle');
|
||||
sessionActivityPhaseRef.current = latest;
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, `${source}:cooldown_timeout`);
|
||||
}, 2000);
|
||||
sessionCooldownTimersRef.current.set(sessionId, timer);
|
||||
}
|
||||
}, [updateSessionStatus]);
|
||||
|
||||
const refreshSessionActivityStatus = React.useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (sessionActivityRefreshInFlightRef.current) {
|
||||
return sessionActivityRefreshInFlightRef.current;
|
||||
}
|
||||
if (now - sessionActivityLastRefreshAtRef.current < 1500) {
|
||||
return;
|
||||
}
|
||||
sessionActivityLastRefreshAtRef.current = now;
|
||||
|
||||
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
|
||||
const observed = new Set<string>();
|
||||
const currentSessions = useSessionStore.getState().sessions;
|
||||
const knownSessionIds = new Set(currentSessions.map((session) => session.id));
|
||||
|
||||
for (const [sessionId, raw] of Object.entries(statusMap)) {
|
||||
if (!sessionId || !raw) continue;
|
||||
observed.add(sessionId);
|
||||
const phase: 'idle' | 'busy' | 'cooldown' =
|
||||
raw.type === 'cooldown'
|
||||
? 'cooldown'
|
||||
: raw.type === 'busy' || raw.type === 'retry'
|
||||
? 'busy'
|
||||
: 'idle';
|
||||
updateSessionActivityPhase(sessionId, phase, 'snapshot');
|
||||
}
|
||||
|
||||
for (const [sessionId, phase] of sessionActivityPhaseRef.current.entries()) {
|
||||
if (!knownSessionIds.has(sessionId)) continue;
|
||||
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'snapshot_missing');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const task = (async (): Promise<void> => {
|
||||
try {
|
||||
const webServerActivity = await opencodeClient.getWebServerSessionActivity();
|
||||
if (webServerActivity && Object.keys(webServerActivity).length > 0) {
|
||||
applyStatusMap(webServerActivity);
|
||||
return;
|
||||
}
|
||||
|
||||
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
|
||||
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
|
||||
applyStatusMap(globalStatusMap);
|
||||
}
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
})().finally(() => {
|
||||
sessionActivityRefreshInFlightRef.current = null;
|
||||
});
|
||||
|
||||
sessionActivityRefreshInFlightRef.current = task;
|
||||
return task;
|
||||
}, [updateSessionActivityPhase]);
|
||||
|
||||
const clearSessionActivityTimers = React.useCallback(() => {
|
||||
const cooldownTimers = sessionCooldownTimersRef.current;
|
||||
for (const timer of cooldownTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
cooldownTimers.clear();
|
||||
sessionActivityPhaseRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const repairSessionDerivedState = React.useCallback((
|
||||
reason: string,
|
||||
options?: { refreshActivity?: boolean; pollStatus?: boolean }
|
||||
) => {
|
||||
const refreshActivity = options?.refreshActivity !== false;
|
||||
const pollStatus = options?.pollStatus !== false;
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Repairing derived session state', { reason, refreshActivity, pollStatus });
|
||||
}
|
||||
|
||||
if (refreshActivity) {
|
||||
void refreshSessionActivityStatus();
|
||||
}
|
||||
|
||||
if (pollStatus) {
|
||||
triggerSessionStatusPoll();
|
||||
}
|
||||
}, [refreshSessionActivityStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const nextSessionId = currentSessionId ?? null;
|
||||
const prevSessionId = previousSessionIdRef.current;
|
||||
const nextDirectory = resolveSessionDirectoryForStatus(nextSessionId);
|
||||
const prevDirectory = previousSessionDirectoryRef.current;
|
||||
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
// Clear the message cache on session switch to free memory
|
||||
messageCache.clear();
|
||||
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
|
||||
// Clear the message cache on session switch to free memory
|
||||
messageCache.clear();
|
||||
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
// Removed: void refreshSessionStatus();
|
||||
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
|
||||
repairSessionDerivedState('session_switch_directory');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = nextSessionId;
|
||||
previousSessionDirectoryRef.current = nextDirectory;
|
||||
}, [currentSessionId, resolveSessionDirectoryForStatus]);
|
||||
}, [currentSessionId, repairSessionDerivedState, resolveSessionDirectoryForStatus]);
|
||||
|
||||
const handleEvent = React.useCallback((event: EventData) => {
|
||||
lastEventTimestampRef.current = Date.now();
|
||||
@@ -889,7 +1013,8 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
if (sessionId && statusType) {
|
||||
if (statusType === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false });
|
||||
} else if (statusType === 'retry') {
|
||||
updateSessionStatus(sessionId, {
|
||||
type: 'retry',
|
||||
@@ -918,14 +1043,28 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
? metadataObj.next
|
||||
: undefined,
|
||||
}, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:session.status', { syncStatus: false });
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'sse:session.status', { syncStatus: false });
|
||||
repairSessionDerivedState('session.status_idle', { refreshActivity: false });
|
||||
}
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openchamber:session-activity':
|
||||
{
|
||||
const sessionId = readStringProp(props, ['sessionId', 'sessionID']);
|
||||
const phase = typeof props.phase === 'string' ? props.phase : null;
|
||||
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
|
||||
updateSessionActivityPhase(sessionId, phase, 'sse:openchamber:session-activity');
|
||||
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openchamber:session-status':
|
||||
{
|
||||
const sessionId = readStringProp(props, ['sessionId', 'sessionID']);
|
||||
@@ -937,6 +1076,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
// Update session status
|
||||
if (status === 'busy') {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
} else if (status === 'retry') {
|
||||
const metadata = (typeof props.metadata === 'object' && props.metadata !== null) ? props.metadata as Record<string, unknown> : {};
|
||||
updateSessionStatus(sessionId, {
|
||||
@@ -945,8 +1085,13 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
message: typeof metadata.message === 'string' ? metadata.message : undefined,
|
||||
next: typeof metadata.next === 'number' ? metadata.next : undefined,
|
||||
}, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'busy', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
} else {
|
||||
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:openchamber:session-status');
|
||||
updateSessionActivityPhase(sessionId, 'idle', 'sse:openchamber:session-status', { syncStatus: false });
|
||||
if (needsAttention) {
|
||||
repairSessionDerivedState('openchamber.session-status_attention_idle', { refreshActivity: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Update attention state in the same update to ensure atomicity
|
||||
@@ -1008,18 +1153,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.part.updated for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const shouldKeepSyntheticUserText = (value: unknown): boolean => {
|
||||
const text = typeof value === 'string' ? value.trim() : '';
|
||||
if (!text) return false;
|
||||
@@ -1043,10 +1176,14 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
};
|
||||
|
||||
let roleInfo = 'assistant';
|
||||
const existingMessage = getLatestMessageFromStore(sessionId, messageId);
|
||||
const existingPartForType = existingMessage?.parts?.find((item) => item?.id === partExt.id);
|
||||
const existingPartType = typeof (existingPartForType as { type?: unknown } | undefined)?.type === 'string'
|
||||
? (existingPartForType as { type: string }).type
|
||||
: undefined;
|
||||
if (messageInfo && typeof (messageInfo as { role?: unknown }).role === 'string') {
|
||||
roleInfo = (messageInfo as { role?: string }).role as string;
|
||||
} else {
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
if (existingMessage) {
|
||||
const existingRole = (existingMessage.info as Record<string, unknown>).role;
|
||||
if (typeof existingRole === 'string') {
|
||||
@@ -1069,11 +1206,30 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const messagePart: Part = {
|
||||
const updatedPartId = readStringProp(partExt, ['id', 'partID', 'partId']);
|
||||
const directory = readEventDirectory(props);
|
||||
const partTypeHintKey = updatedPartId ? `${directory}:${messageId}:${updatedPartId}` : null;
|
||||
const hintedPartType = partTypeHintKey ? partTypeHintsByKeyRef.current.get(partTypeHintKey) : undefined;
|
||||
|
||||
const resolvedPartType =
|
||||
part.type ||
|
||||
existingPartType ||
|
||||
hintedPartType ||
|
||||
'text';
|
||||
|
||||
const messagePartBase: Part = {
|
||||
...part,
|
||||
type: part.type || 'text',
|
||||
type: resolvedPartType,
|
||||
} as Part;
|
||||
|
||||
const messagePart: Part = {
|
||||
...messagePartBase,
|
||||
} as Part;
|
||||
|
||||
if (partTypeHintKey && typeof resolvedPartType === 'string' && resolvedPartType.length > 0) {
|
||||
writePartTypeHint(partTypeHintKey, resolvedPartType);
|
||||
}
|
||||
|
||||
if (roleInfo === 'assistant') {
|
||||
const partType = (messagePart as { type?: unknown }).type;
|
||||
const partTime = (messagePart as { time?: { end?: unknown } }).time;
|
||||
@@ -1112,10 +1268,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
typeof currentStatus.confirmedAt === 'number' &&
|
||||
Date.now() - currentStatus.confirmedAt < 1200;
|
||||
if (!currentStatus || currentStatus.type === 'idle') {
|
||||
if (recentlyConfirmedIdle) {
|
||||
break;
|
||||
if (!recentlyConfirmedIdle) {
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||
}
|
||||
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1151,37 +1306,44 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.part.delta for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
const existingMessage = getLatestMessageFromStore(sessionId, messageId);
|
||||
const existingPart = existingMessage?.parts?.find((item) => item?.id === partId);
|
||||
const existingRole = (existingMessage?.info as Record<string, unknown> | undefined)?.role;
|
||||
const roleInfo = typeof existingRole === 'string' ? existingRole : 'assistant';
|
||||
|
||||
if (!existingPart) {
|
||||
if (field === 'text' || field === 'content' || field === 'value') {
|
||||
const directory = readEventDirectory(props);
|
||||
const deltaPartTypeHint =
|
||||
readStringProp(props, ['partType', 'type', 'part_type']) ||
|
||||
readStringProp(props, ['kind']);
|
||||
const partTypeHintKey = `${directory}:${messageId}:${partId}`;
|
||||
const hintedPartType = partTypeHintsByKeyRef.current.get(partTypeHintKey);
|
||||
const bootstrappedPartType =
|
||||
typeof deltaPartTypeHint === 'string' && deltaPartTypeHint.trim().length > 0
|
||||
? deltaPartTypeHint
|
||||
: (typeof hintedPartType === 'string' && hintedPartType.trim().length > 0
|
||||
? hintedPartType
|
||||
: 'text');
|
||||
|
||||
const bootstrappedPart = {
|
||||
id: partId,
|
||||
type: bootstrappedPartType,
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
delta,
|
||||
[field]: '',
|
||||
} as unknown as Part;
|
||||
|
||||
if (typeof bootstrappedPartType === 'string' && bootstrappedPartType.length > 0) {
|
||||
writePartTypeHint(partTypeHintKey, bootstrappedPartType);
|
||||
}
|
||||
|
||||
addStreamingPart(sessionId, messageId, bootstrappedPart, roleInfo);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
const existingPart = existingMessage?.parts?.find((item) => item?.id === partId);
|
||||
if (!existingPart) {
|
||||
break;
|
||||
}
|
||||
|
||||
const existingPartRecord = existingPart as Record<string, unknown>;
|
||||
const existingFieldValue = existingPartRecord[field];
|
||||
const updatedPart: Part = {
|
||||
...existingPart,
|
||||
[field]: `${typeof existingFieldValue === 'string' ? existingFieldValue : ''}${delta}`,
|
||||
} as Part;
|
||||
|
||||
let roleInfo = 'assistant';
|
||||
const existingRole = (existingMessage?.info as Record<string, unknown> | undefined)?.role;
|
||||
if (typeof existingRole === 'string') {
|
||||
roleInfo = existingRole;
|
||||
}
|
||||
|
||||
if (roleInfo === 'assistant' && delta.length > 0) {
|
||||
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
|
||||
const recentlyConfirmedIdle =
|
||||
@@ -1196,7 +1358,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
trackMessage(messageId, 'part_delta_received', { role: roleInfo, field });
|
||||
addStreamingPart(sessionId, messageId, updatedPart, roleInfo);
|
||||
applyPartDelta(sessionId, messageId, partId, field, delta, roleInfo);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1232,18 +1394,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
pendingMessageStallTimersRef.current.delete(sessionId);
|
||||
}
|
||||
|
||||
const trimmedHeadMaxId = useSessionStore.getState().sessionMemoryState.get(sessionId)?.trimmedHeadMaxId;
|
||||
if (trimmedHeadMaxId && !isIdNewer(messageId, trimmedHeadMaxId)) {
|
||||
if (streamDebugEnabled()) {
|
||||
console.debug('[useEventStream] Skipping message.updated for trimmed message', {
|
||||
sessionId,
|
||||
messageId,
|
||||
trimmedHeadMaxId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (streamDebugEnabled()) {
|
||||
try {
|
||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
|
||||
@@ -1404,7 +1554,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
if (!missingMessageHydrationRef.current.has(hydrateKey)) {
|
||||
missingMessageHydrationRef.current.add(hydrateKey);
|
||||
void opencodeClient
|
||||
.getSessionMessages(sessionId, 50)
|
||||
.getSessionMessages(sessionId)
|
||||
.then((messages) => {
|
||||
useSessionStore.getState().syncMessages(sessionId, messages);
|
||||
})
|
||||
@@ -1415,6 +1565,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
if (partsArray.length > 0) {
|
||||
const directory = readEventDirectory(props);
|
||||
for (let i = 0; i < partsArray.length; i++) {
|
||||
const serverPart = partsArray[i];
|
||||
const isSynthetic = (serverPart as Record<string, unknown>).synthetic === true;
|
||||
@@ -1434,6 +1585,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
sessionID: (serverPart as { sessionID?: string })?.sessionID || sessionId,
|
||||
messageID: (serverPart as { messageID?: string })?.messageID || messageId,
|
||||
} as Part;
|
||||
if (typeof enrichedPart.id === 'string' && typeof enrichedPart.type === 'string') {
|
||||
writePartTypeHint(`${directory}:${messageId}:${enrichedPart.id}`, enrichedPart.type);
|
||||
}
|
||||
addStreamingPart(sessionId, messageId, enrichedPart, 'user');
|
||||
}
|
||||
}
|
||||
@@ -1443,7 +1597,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
|
||||
const existingMessage = getMessageFromStore(sessionId, messageId);
|
||||
const existingLen = computeTextLength(existingMessage?.parts || []);
|
||||
const existingStopMarker = (existingMessage?.info as { finish?: string } | undefined)?.finish === 'stop';
|
||||
|
||||
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts;
|
||||
@@ -1461,26 +1614,10 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
if (!hasParts && !completedFromServer && !hasCompletedStatus && !eventHasStopFinish && !eventHasErrorFinish) break;
|
||||
|
||||
if ((messageExt as { role?: unknown }).role === 'assistant' && hasParts) {
|
||||
const hasQuestionTool = partsArray.some((part) => (
|
||||
part?.type === 'tool'
|
||||
&& typeof (part as { tool?: unknown }).tool === 'string'
|
||||
&& (part as { tool: string }).tool.toLowerCase() === 'question'
|
||||
));
|
||||
if (hasQuestionTool) {
|
||||
requestPendingQuestionsRefresh();
|
||||
}
|
||||
const messageInfoOnly = { ...messageExt } as Record<string, unknown>;
|
||||
delete messageInfoOnly.parts;
|
||||
|
||||
const incomingLen = computeTextLength(partsArray);
|
||||
const wouldShrink = existingLen > 0 && incomingLen + TEXT_SHRINK_TOLERANCE < existingLen;
|
||||
|
||||
if (wouldShrink && !eventHasStopFinish) {
|
||||
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageInfo(sessionId, messageId, message as unknown as Message);
|
||||
updateMessageInfo(sessionId, messageId, messageInfoOnly as unknown as Message);
|
||||
|
||||
const messageRole = typeof (message as { role?: unknown }).role === 'string'
|
||||
? (message as { role: string }).role
|
||||
@@ -1520,33 +1657,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasParts && (messageExt as { role?: unknown }).role !== 'user') {
|
||||
const storeState = useSessionStore.getState();
|
||||
const existingMessages = storeState.messages.get(sessionId) || [];
|
||||
const existingMessageForSession = existingMessages.find((m) => m.info.id === messageId);
|
||||
const needsInjection = !existingMessageForSession || existingMessageForSession.parts.length === 0;
|
||||
|
||||
trackMessage(
|
||||
messageId,
|
||||
needsInjection ? 'server_parts_injected' : 'server_parts_refreshed',
|
||||
{ count: partsArray.length }
|
||||
);
|
||||
|
||||
const partsToInject = partsArray;
|
||||
|
||||
for (let i = 0; i < partsToInject.length; i++) {
|
||||
const serverPart = partsToInject[i];
|
||||
const enrichedPart: Part = {
|
||||
...serverPart,
|
||||
type: serverPart?.type || 'text',
|
||||
sessionID: serverPart?.sessionID || sessionId,
|
||||
messageID: serverPart?.messageID || messageId,
|
||||
} as Part;
|
||||
addStreamingPart(sessionId, messageId, enrichedPart, (messageExt as { role?: string }).role as string);
|
||||
trackMessage(messageId, `server_part_${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
const messageTime = (message as { time?: { completed?: unknown } }).time;
|
||||
const completedCandidate = (messageTime as { completed?: unknown } | undefined)?.completed;
|
||||
const hasCompletedTimestamp = typeof completedCandidate === 'number' && Number.isFinite(completedCandidate);
|
||||
@@ -1593,7 +1703,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
void saveSessionCursor(sessionId, messageId, timeCompleted);
|
||||
|
||||
completeStreamingMessage(sessionId, messageId);
|
||||
// Removed: void refreshSessionStatus();
|
||||
repairSessionDerivedState('assistant_message_completed');
|
||||
|
||||
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
|
||||
const messageSessionId: string =
|
||||
@@ -1938,6 +2048,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}, [
|
||||
currentSessionId,
|
||||
addStreamingPart,
|
||||
applyPartDelta,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
addPermission,
|
||||
@@ -1957,7 +2068,10 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
bootstrapState,
|
||||
effectiveDirectory,
|
||||
updateSessionStatus,
|
||||
updateSessionActivityPhase,
|
||||
repairSessionDerivedState,
|
||||
dispatchRuntimeNotification,
|
||||
writePartTypeHint,
|
||||
]);
|
||||
|
||||
// --- Stable callback refs (Part A) ---
|
||||
@@ -2029,7 +2143,6 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isCleaningUpRef.current = false;
|
||||
}, []);
|
||||
|
||||
@@ -2070,7 +2183,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
lastEventTimestampRef.current = Date.now();
|
||||
publishStatus('connected', null);
|
||||
checkConnection();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('stream_open');
|
||||
|
||||
requestPendingPermissionsRefreshRef.current(shouldRefresh);
|
||||
|
||||
@@ -2154,6 +2267,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh,
|
||||
stableHandleEvent,
|
||||
stableBootstrapState,
|
||||
repairSessionDerivedState,
|
||||
effectiveDirectory,
|
||||
debugConnectionState,
|
||||
]);
|
||||
@@ -2231,7 +2345,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('visibility_restore');
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('visibility_restore');
|
||||
|
||||
const isStalled = Date.now() - lastEventTimestampRef.current > 45000;
|
||||
if (isStalled) {
|
||||
@@ -2247,9 +2361,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('visibility_restore_resume');
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
@@ -2261,7 +2373,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
if (visibilityStateRef.current === 'visible') {
|
||||
clearPauseTimeout();
|
||||
maybeBootstrapIfStale('window_focus');
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('window_focus');
|
||||
|
||||
const isStalled = Date.now() - lastEventTimestampRef.current > 45000;
|
||||
if (isStalled) {
|
||||
@@ -2276,8 +2388,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
scheduleSoftResync(sessionId, 'window_focus', getMessageLimit());
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('window_focus_resume');
|
||||
|
||||
publishStatus('connecting', 'Resuming stream');
|
||||
startStream({ resetAttempts: true });
|
||||
@@ -2288,9 +2399,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
const handleOnline = () => {
|
||||
onlineStatusRef.current = true;
|
||||
maybeBootstrapIfStale('network_restored');
|
||||
repairSessionDerivedState('network_restored');
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
if (pendingResumeRef.current || !unsubscribeRef.current) {
|
||||
triggerSessionStatusPoll();
|
||||
publishStatus('connecting', 'Network restored');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
@@ -2320,8 +2431,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
requestSessionMetadataRefresh(sessionId);
|
||||
}
|
||||
requestPendingPermissionsRefreshRef.current(false);
|
||||
// Removed: void refreshSessionStatus();
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('page_show');
|
||||
startStream({ resetAttempts: true });
|
||||
}
|
||||
};
|
||||
@@ -2355,7 +2465,7 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
);
|
||||
|
||||
if (hasBusySessions) {
|
||||
triggerSessionStatusPoll();
|
||||
repairSessionDerivedState('stale_check_busy_sessions');
|
||||
}
|
||||
if (now - lastEventTimestampRef.current > 45000) {
|
||||
Promise.resolve().then(async () => {
|
||||
@@ -2420,6 +2530,8 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
staleCheckIntervalRef.current = null;
|
||||
}
|
||||
|
||||
clearSessionActivityTimers();
|
||||
|
||||
messageCache.clear();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
||||
notifiedMessagesRef.current.clear();
|
||||
@@ -2451,6 +2563,9 @@ export const useEventStream = (options?: { enabled?: boolean }) => {
|
||||
scheduleReconnect,
|
||||
loadMessages,
|
||||
requestSessionMetadataRefresh,
|
||||
refreshSessionActivityStatus,
|
||||
clearSessionActivityTimers,
|
||||
repairSessionDerivedState,
|
||||
|
||||
|
||||
shouldHoldConnection,
|
||||
|
||||
@@ -394,7 +394,7 @@ export const useKeyboardShortcuts = () => {
|
||||
if (primedUntil && now < primedUntil) {
|
||||
e.preventDefault();
|
||||
resetAbortPriming();
|
||||
void abortCurrentOperation();
|
||||
void abortCurrentOperation(sessionId || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { animate, type AnimationPlaybackControls } from 'motion';
|
||||
|
||||
type ScrollEngineOptions = {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -7,85 +8,106 @@ type ScrollEngineOptions = {
|
||||
|
||||
type ScrollOptions = {
|
||||
instant?: boolean;
|
||||
followBottom?: boolean; // Dynamically track bottom during animation
|
||||
followBottom?: boolean; // Dynamically track bottom during streaming
|
||||
};
|
||||
|
||||
type ScrollEngineResult = {
|
||||
handleScroll: () => void;
|
||||
scrollToPosition: (position: number, options?: ScrollOptions) => void;
|
||||
forceManualMode: () => void;
|
||||
cancelFollow: () => void;
|
||||
isAtTop: boolean;
|
||||
isFollowingBottom: boolean;
|
||||
isManualOverrideActive: () => boolean;
|
||||
getScrollTop: () => number;
|
||||
getScrollHeight: () => number;
|
||||
getClientHeight: () => number;
|
||||
};
|
||||
|
||||
const ANIMATION_DURATION_MS = 160;
|
||||
// Spring config for one-shot scroll-to-bottom (button click, session switch).
|
||||
const FAST_SPRING = {
|
||||
type: 'spring' as const,
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
};
|
||||
|
||||
// Exponential smoothing factor for the follow-bottom rAF loop.
|
||||
// Each frame: scrollTop += (target - scrollTop) * LERP_FACTOR
|
||||
// ~0.12-0.18 gives a smooth camera-follow feel at 60fps.
|
||||
const LERP_FACTOR = 0.14;
|
||||
|
||||
// When the remaining distance is below this, snap exactly to bottom.
|
||||
const SNAP_EPSILON = 0.5;
|
||||
|
||||
export const useScrollEngine = ({
|
||||
containerRef,
|
||||
}: ScrollEngineOptions): ScrollEngineResult => {
|
||||
const [isAtTop, setIsAtTop] = React.useState(true);
|
||||
const [isFollowingBottom, setIsFollowingBottom] = React.useState(false);
|
||||
|
||||
const atTopRef = React.useRef(true);
|
||||
const manualOverrideRef = React.useRef(false);
|
||||
const animationFrameRef = React.useRef<number | null>(null);
|
||||
const animationStartRef = React.useRef<number | null>(null);
|
||||
const animationFromRef = React.useRef(0);
|
||||
const animationTargetRef = React.useRef(0);
|
||||
const followBottomRef = React.useRef(false);
|
||||
|
||||
const cancelAnimation = React.useCallback(() => {
|
||||
if (animationFrameRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(animationFrameRef.current);
|
||||
// One-shot spring animation (for scroll-to-bottom button etc.)
|
||||
const scrollAnimRef = React.useRef<AnimationPlaybackControls | undefined>(undefined);
|
||||
|
||||
// Continuous follow-bottom rAF loop (for streaming)
|
||||
const followRafRef = React.useRef<number | null>(null);
|
||||
const followActiveRef = React.useRef(false);
|
||||
|
||||
const cancelSpring = React.useCallback(() => {
|
||||
if (scrollAnimRef.current) {
|
||||
scrollAnimRef.current.stop();
|
||||
scrollAnimRef.current = undefined;
|
||||
}
|
||||
|
||||
animationFrameRef.current = null;
|
||||
animationStartRef.current = null;
|
||||
followBottomRef.current = false;
|
||||
}, []);
|
||||
|
||||
const runAnimationFrame = React.useCallback(
|
||||
(timestamp: number) => {
|
||||
const cancelFollow = React.useCallback(() => {
|
||||
if (followRafRef.current !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(followRafRef.current);
|
||||
followRafRef.current = null;
|
||||
}
|
||||
followActiveRef.current = false;
|
||||
setIsFollowingBottom(false);
|
||||
}, []);
|
||||
|
||||
const cancelAll = React.useCallback(() => {
|
||||
cancelSpring();
|
||||
cancelFollow();
|
||||
}, [cancelSpring, cancelFollow]);
|
||||
|
||||
// Continuous lerp loop that chases scrollHeight - clientHeight.
|
||||
const startFollowLoop = React.useCallback(() => {
|
||||
if (followActiveRef.current) return; // already running
|
||||
followActiveRef.current = true;
|
||||
setIsFollowingBottom(true);
|
||||
|
||||
const tick = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
cancelAnimation();
|
||||
if (!container || !followActiveRef.current) {
|
||||
followActiveRef.current = false;
|
||||
followRafRef.current = null;
|
||||
setIsFollowingBottom(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationStartRef.current === null) {
|
||||
animationStartRef.current = timestamp;
|
||||
}
|
||||
const target = container.scrollHeight - container.clientHeight;
|
||||
const current = container.scrollTop;
|
||||
const delta = target - current;
|
||||
|
||||
// If followBottom mode, dynamically update target to current bottom
|
||||
if (followBottomRef.current) {
|
||||
animationTargetRef.current = container.scrollHeight - container.clientHeight;
|
||||
}
|
||||
|
||||
const progress = Math.min(1, (timestamp - animationStartRef.current) / ANIMATION_DURATION_MS);
|
||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const from = animationFromRef.current;
|
||||
const target = animationTargetRef.current;
|
||||
const nextTop = from + (target - from) * easedProgress;
|
||||
|
||||
container.scrollTop = nextTop;
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
if (Math.abs(delta) <= SNAP_EPSILON) {
|
||||
container.scrollTop = target;
|
||||
// Don't stop — keep running so next content growth is caught immediately.
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = target;
|
||||
cancelAnimation();
|
||||
container.scrollTop = current + delta * LERP_FACTOR;
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
if (atTopRef.current) {
|
||||
atTopRef.current = false;
|
||||
setIsAtTop(false);
|
||||
}
|
||||
},
|
||||
[cancelAnimation, containerRef, setIsAtTop]
|
||||
);
|
||||
followRafRef.current = window.requestAnimationFrame(tick);
|
||||
}, [containerRef]);
|
||||
|
||||
const scrollToPosition = React.useCallback(
|
||||
(position: number, options?: ScrollOptions) => {
|
||||
@@ -98,8 +120,9 @@ export const useScrollEngine = ({
|
||||
|
||||
manualOverrideRef.current = false;
|
||||
|
||||
// Instant scroll (session switch, etc.)
|
||||
if (typeof window === 'undefined' || preferInstant) {
|
||||
cancelAnimation();
|
||||
cancelAll();
|
||||
container.scrollTop = target;
|
||||
|
||||
const atTop = target <= 1;
|
||||
@@ -107,37 +130,41 @@ export const useScrollEngine = ({
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If followBottom animation is already running, don't restart - let it continue
|
||||
if (followBottom && followBottomRef.current && animationFrameRef.current !== null) {
|
||||
// Follow-bottom mode: start the continuous lerp loop
|
||||
if (followBottom) {
|
||||
cancelSpring();
|
||||
startFollowLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation();
|
||||
// One-shot scroll: stop everything and use spring animation
|
||||
cancelAll();
|
||||
|
||||
const distance = Math.abs(target - container.scrollTop);
|
||||
if (distance <= 0.5) {
|
||||
if (distance <= SNAP_EPSILON) {
|
||||
container.scrollTop = target;
|
||||
|
||||
const atTop = target <= 1;
|
||||
if (atTopRef.current !== atTop) {
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
animationFromRef.current = container.scrollTop;
|
||||
animationTargetRef.current = target;
|
||||
animationStartRef.current = null;
|
||||
followBottomRef.current = followBottom;
|
||||
animationFrameRef.current = window.requestAnimationFrame(runAnimationFrame);
|
||||
scrollAnimRef.current = animate(container.scrollTop, target, {
|
||||
...FAST_SPRING,
|
||||
onUpdate: (v) => {
|
||||
container.scrollTop = v;
|
||||
},
|
||||
onComplete: () => {
|
||||
scrollAnimRef.current = undefined;
|
||||
},
|
||||
});
|
||||
},
|
||||
[cancelAnimation, containerRef, runAnimationFrame, setIsAtTop]
|
||||
[cancelAll, cancelSpring, containerRef, setIsAtTop, startFollowLoop]
|
||||
);
|
||||
|
||||
const forceManualMode = React.useCallback(() => {
|
||||
@@ -146,7 +173,8 @@ export const useScrollEngine = ({
|
||||
|
||||
const markManualOverride = React.useCallback(() => {
|
||||
manualOverrideRef.current = true;
|
||||
}, []);
|
||||
cancelFollow();
|
||||
}, [cancelFollow]);
|
||||
|
||||
const isManualOverrideActive = React.useCallback(() => {
|
||||
return manualOverrideRef.current;
|
||||
@@ -168,17 +196,16 @@ export const useScrollEngine = ({
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (manualOverrideRef.current && animationFrameRef.current !== null) {
|
||||
cancelAnimation();
|
||||
if (manualOverrideRef.current && scrollAnimRef.current) {
|
||||
cancelSpring();
|
||||
}
|
||||
|
||||
const atTop = container.scrollTop <= 1;
|
||||
|
||||
if (atTopRef.current !== atTop) {
|
||||
atTopRef.current = atTop;
|
||||
setIsAtTop(atTop);
|
||||
}
|
||||
}, [cancelAnimation, containerRef]);
|
||||
}, [cancelSpring, containerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -195,16 +222,18 @@ export const useScrollEngine = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimation();
|
||||
cancelAll();
|
||||
};
|
||||
}, [cancelAnimation]);
|
||||
}, [cancelAll]);
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
handleScroll,
|
||||
scrollToPosition,
|
||||
forceManualMode,
|
||||
cancelFollow,
|
||||
isAtTop,
|
||||
isFollowingBottom,
|
||||
isManualOverrideActive,
|
||||
getScrollTop,
|
||||
getScrollHeight,
|
||||
@@ -214,7 +243,9 @@ export const useScrollEngine = ({
|
||||
handleScroll,
|
||||
scrollToPosition,
|
||||
forceManualMode,
|
||||
cancelFollow,
|
||||
isAtTop,
|
||||
isFollowingBottom,
|
||||
isManualOverrideActive,
|
||||
getScrollTop,
|
||||
getScrollHeight,
|
||||
|
||||
@@ -397,6 +397,32 @@ svg.animate-spin {
|
||||
}
|
||||
}
|
||||
|
||||
/* Starfield twinkle — dots fade through varying brightness like stars */
|
||||
@keyframes star-twinkle {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-opacity-dim {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.app-region-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
@@ -759,6 +785,22 @@ html:not(.dark) .chat-scroll {
|
||||
font-size: var(--text-code) !important;
|
||||
}
|
||||
|
||||
/* Reasoning markdown renders at meta size, dimmed and italic. */
|
||||
.streamdown-content.streamdown-reasoning {
|
||||
font-size: var(--text-meta);
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.streamdown-content.streamdown-reasoning strong,
|
||||
.streamdown-content.streamdown-reasoning b,
|
||||
.streamdown-content.streamdown-reasoning [data-streamdown="strong"],
|
||||
.streamdown-content.streamdown-reasoning [data-streamdown="bold"] {
|
||||
font-weight: inherit !important;
|
||||
color: inherit !important;
|
||||
font-style: inherit !important;
|
||||
}
|
||||
|
||||
.streamdown-content.streamdown-tool code,
|
||||
.streamdown-content.streamdown-tool pre {
|
||||
font-size: inherit !important;
|
||||
@@ -1338,7 +1380,7 @@ textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||
.animate-grid-pulse,
|
||||
.animate-attention-diamond-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -516,7 +516,6 @@ export interface SettingsPayload {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
showTextJustificationActivity?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
@@ -524,8 +523,13 @@ export interface SettingsPayload {
|
||||
autoDeleteAfterDays?: number;
|
||||
queueModeEnabled?: boolean;
|
||||
gitmojiEnabled?: boolean;
|
||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||
inputSpellcheckEnabled?: boolean;
|
||||
showToolFileIcons?: boolean;
|
||||
showExpandedBashTools?: boolean;
|
||||
showExpandedEditTools?: boolean;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
activityRenderMode?: 'collapsed' | 'summary';
|
||||
mermaidRenderingMode?: 'svg' | 'ascii';
|
||||
fontSize?: number;
|
||||
terminalFontSize?: number;
|
||||
padding?: number;
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { DesktopSettings } from '@/lib/desktop';
|
||||
|
||||
type AppearanceSlice = {
|
||||
showReasoningTraces: boolean;
|
||||
showTextJustificationActivity: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
@@ -24,7 +23,6 @@ type AppearanceSlice = {
|
||||
maxLastMessageLength: number;
|
||||
autoDeleteEnabled: boolean;
|
||||
autoDeleteAfterDays: number;
|
||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||
fontSize: number;
|
||||
terminalFontSize: number;
|
||||
padding: number;
|
||||
@@ -45,7 +43,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
|
||||
let previous: AppearanceSlice = {
|
||||
showReasoningTraces: useUIStore.getState().showReasoningTraces,
|
||||
showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity,
|
||||
showDeletionDialog: useUIStore.getState().showDeletionDialog,
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
notificationMode: useUIStore.getState().notificationMode,
|
||||
@@ -60,7 +57,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
maxLastMessageLength: useUIStore.getState().maxLastMessageLength,
|
||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||
toolCallExpansion: useUIStore.getState().toolCallExpansion,
|
||||
fontSize: useUIStore.getState().fontSize,
|
||||
terminalFontSize: useUIStore.getState().terminalFontSize,
|
||||
padding: useUIStore.getState().padding,
|
||||
@@ -93,7 +89,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
useUIStore.subscribe((state) => {
|
||||
const current: AppearanceSlice = {
|
||||
showReasoningTraces: state.showReasoningTraces,
|
||||
showTextJustificationActivity: state.showTextJustificationActivity,
|
||||
showDeletionDialog: state.showDeletionDialog,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
@@ -108,7 +103,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
maxLastMessageLength: state.maxLastMessageLength,
|
||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||
toolCallExpansion: state.toolCallExpansion,
|
||||
fontSize: state.fontSize,
|
||||
terminalFontSize: state.terminalFontSize,
|
||||
padding: state.padding,
|
||||
@@ -123,9 +117,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.showReasoningTraces !== previous.showReasoningTraces) {
|
||||
diff.showReasoningTraces = current.showReasoningTraces;
|
||||
}
|
||||
if (current.showTextJustificationActivity !== previous.showTextJustificationActivity) {
|
||||
diff.showTextJustificationActivity = current.showTextJustificationActivity;
|
||||
}
|
||||
if (current.showDeletionDialog !== previous.showDeletionDialog) {
|
||||
diff.showDeletionDialog = current.showDeletionDialog;
|
||||
}
|
||||
@@ -168,9 +159,6 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.autoDeleteAfterDays !== previous.autoDeleteAfterDays) {
|
||||
diff.autoDeleteAfterDays = current.autoDeleteAfterDays;
|
||||
}
|
||||
if (current.toolCallExpansion !== previous.toolCallExpansion) {
|
||||
diff.toolCallExpansion = current.toolCallExpansion;
|
||||
}
|
||||
if (current.fontSize !== previous.fontSize) {
|
||||
diff.fontSize = current.fontSize;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export type DesktopSettings = {
|
||||
securityScopedBookmarks?: string[];
|
||||
pinnedDirectories?: string[];
|
||||
showReasoningTraces?: boolean;
|
||||
showTextJustificationActivity?: boolean;
|
||||
showDeletionDialog?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
@@ -117,8 +116,13 @@ export type DesktopSettings = {
|
||||
gitProviderId?: string;
|
||||
gitModelId?: string;
|
||||
pwaAppName?: string;
|
||||
toolCallExpansion?: 'collapsed' | 'activity' | 'detailed' | 'changes';
|
||||
inputSpellcheckEnabled?: boolean;
|
||||
showToolFileIcons?: boolean;
|
||||
showExpandedBashTools?: boolean;
|
||||
showExpandedEditTools?: boolean;
|
||||
chatRenderMode?: 'sorted' | 'live';
|
||||
activityRenderMode?: 'collapsed' | 'summary';
|
||||
mermaidRenderingMode?: 'svg' | 'ascii';
|
||||
userMessageRenderingMode?: 'markdown' | 'plain';
|
||||
stickyUserHeader?: boolean;
|
||||
fontSize?: number;
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { FILE_TYPE_ICON_IDS } from '@/lib/fileTypeIconIds';
|
||||
import spriteUrl from '../assets/icons/file-types/sprite.svg';
|
||||
import spriteContent from '../assets/icons/file-types/sprite.svg?raw';
|
||||
|
||||
type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
const FILE_TYPE_SPRITE_ROOT_ID = 'oc-file-type-icon-sprite-root';
|
||||
|
||||
const mountFileTypeSprite = (): void => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.getElementById(FILE_TYPE_SPRITE_ROOT_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attach = () => {
|
||||
if (document.getElementById(FILE_TYPE_SPRITE_ROOT_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.id = FILE_TYPE_SPRITE_ROOT_ID;
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
root.style.position = 'absolute';
|
||||
root.style.width = '0';
|
||||
root.style.height = '0';
|
||||
root.style.overflow = 'hidden';
|
||||
root.innerHTML = spriteContent;
|
||||
document.body.appendChild(root);
|
||||
};
|
||||
|
||||
if (document.body) {
|
||||
attach();
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', attach, { once: true });
|
||||
};
|
||||
|
||||
mountFileTypeSprite();
|
||||
|
||||
const fileNameIconMap: Record<string, string> = {
|
||||
dockerfile: 'docker',
|
||||
makefile: 'makefile',
|
||||
@@ -206,7 +243,7 @@ export const getFileTypeIconHref = (
|
||||
const resolvedBaseIconName = resolveIconName(filePath, options?.extension);
|
||||
const baseIconName = FILE_TYPE_ICON_IDS.has(resolvedBaseIconName) ? resolvedBaseIconName : fallbackIconName;
|
||||
const iconName = selectVariantIconName(baseIconName, options?.themeVariant || 'dark');
|
||||
return `${spriteUrl}#${iconName}`;
|
||||
return `#${iconName}`;
|
||||
};
|
||||
|
||||
export const getFileTypeIconUrl = getFileTypeIconHref;
|
||||
|
||||
@@ -151,7 +151,6 @@ class OpencodeService {
|
||||
|
||||
private globalSseAbortController: AbortController | null = null;
|
||||
private globalSseTask: Promise<void> | null = null;
|
||||
private globalSseLastEventId: string | undefined;
|
||||
private globalSseIsConnected = false;
|
||||
private globalSseListeners: Set<(event: RoutedOpencodeEvent) => void> = new Set();
|
||||
private globalSseOpenListeners: Set<() => void> = new Set();
|
||||
@@ -159,6 +158,7 @@ class OpencodeService {
|
||||
private globalSseQueue: Array<RoutedOpencodeEvent | undefined> = [];
|
||||
private globalSseBuffer: Array<RoutedOpencodeEvent | undefined> = [];
|
||||
private globalSseCoalesced: Map<string, number> = new Map();
|
||||
private globalSseStaleDeltas: Set<string> = new Set();
|
||||
private globalSseFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private globalSseLastFlushAt = 0;
|
||||
|
||||
@@ -1201,41 +1201,6 @@ class OpencodeService {
|
||||
}
|
||||
}
|
||||
|
||||
private parseSseBlock(block: string): { data: unknown; id?: string } | null {
|
||||
if (!block) return null;
|
||||
|
||||
const lines = block.split('\n');
|
||||
const dataLines: string[] = [];
|
||||
let eventId: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).replace(/^\s/, ''));
|
||||
} else if (line.startsWith('id:')) {
|
||||
const candidate = line.slice(3).trim();
|
||||
if (candidate) {
|
||||
eventId = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payloadText = dataLines.join('\n').trim();
|
||||
if (!payloadText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(payloadText) as unknown;
|
||||
return { data, id: eventId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeRoutedSsePayload(raw: unknown): RoutedOpencodeEvent | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null;
|
||||
@@ -1344,6 +1309,71 @@ class OpencodeService {
|
||||
this.globalSseQueue.length = 0;
|
||||
this.globalSseBuffer.length = 0;
|
||||
this.globalSseCoalesced.clear();
|
||||
this.globalSseStaleDeltas.clear();
|
||||
}
|
||||
|
||||
private getGlobalSseDeltaKey(event: RoutedOpencodeEvent): string | null {
|
||||
const payload = event.payload as unknown as Record<string, unknown>;
|
||||
const eventType = typeof payload.type === 'string' ? payload.type : null;
|
||||
if (eventType !== 'message.part.delta') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties =
|
||||
typeof payload.properties === 'object' && payload.properties !== null
|
||||
? (payload.properties as Record<string, unknown>)
|
||||
: null;
|
||||
const messageId = typeof properties?.messageID === 'string'
|
||||
? properties.messageID
|
||||
: typeof properties?.messageId === 'string'
|
||||
? properties.messageId
|
||||
: null;
|
||||
const partId = typeof properties?.partID === 'string'
|
||||
? properties.partID
|
||||
: typeof properties?.partId === 'string'
|
||||
? properties.partId
|
||||
: null;
|
||||
|
||||
if (!messageId || !partId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${event.directory}:${messageId}:${partId}`;
|
||||
}
|
||||
|
||||
private getGlobalSseUpdatedPartKey(event: RoutedOpencodeEvent): string | null {
|
||||
const payload = event.payload as unknown as Record<string, unknown>;
|
||||
const eventType = typeof payload.type === 'string' ? payload.type : null;
|
||||
if (eventType !== 'message.part.updated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties =
|
||||
typeof payload.properties === 'object' && payload.properties !== null
|
||||
? (payload.properties as Record<string, unknown>)
|
||||
: null;
|
||||
const part =
|
||||
properties?.part && typeof properties.part === 'object'
|
||||
? (properties.part as Record<string, unknown>)
|
||||
: null;
|
||||
const messageId = typeof part?.messageID === 'string'
|
||||
? part.messageID
|
||||
: typeof part?.messageId === 'string'
|
||||
? part.messageId
|
||||
: null;
|
||||
const partId = typeof part?.id === 'string'
|
||||
? part.id
|
||||
: typeof part?.partID === 'string'
|
||||
? part.partID
|
||||
: typeof part?.partId === 'string'
|
||||
? part.partId
|
||||
: null;
|
||||
|
||||
if (!messageId || !partId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${event.directory}:${messageId}:${partId}`;
|
||||
}
|
||||
|
||||
private getGlobalSseCoalesceKey(event: RoutedOpencodeEvent): string | null {
|
||||
@@ -1382,6 +1412,14 @@ class OpencodeService {
|
||||
return `openchamber:session-status:${sessionId}`;
|
||||
}
|
||||
|
||||
if (eventType === 'message.part.updated') {
|
||||
const partKey = this.getGlobalSseUpdatedPartKey(event);
|
||||
if (!partKey) {
|
||||
return null;
|
||||
}
|
||||
return `message.part.updated:${partKey}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1396,14 +1434,22 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
const events = this.globalSseQueue;
|
||||
const skip = this.globalSseStaleDeltas.size > 0 ? new Set(this.globalSseStaleDeltas) : undefined;
|
||||
this.globalSseQueue = this.globalSseBuffer;
|
||||
this.globalSseBuffer = events;
|
||||
this.globalSseQueue.length = 0;
|
||||
this.globalSseCoalesced.clear();
|
||||
this.globalSseStaleDeltas.clear();
|
||||
this.globalSseLastFlushAt = Date.now();
|
||||
|
||||
for (const event of events) {
|
||||
if (!event) continue;
|
||||
if (skip) {
|
||||
const deltaKey = this.getGlobalSseDeltaKey(event);
|
||||
if (deltaKey && skip.has(deltaKey)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const listener of this.globalSseListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
@@ -1431,6 +1477,10 @@ class OpencodeService {
|
||||
const existingIndex = this.globalSseCoalesced.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
this.globalSseQueue[existingIndex] = undefined;
|
||||
const updatedPartKey = this.getGlobalSseUpdatedPartKey(event);
|
||||
if (updatedPartKey) {
|
||||
this.globalSseStaleDeltas.add(updatedPartKey);
|
||||
}
|
||||
}
|
||||
this.globalSseCoalesced.set(key, this.globalSseQueue.length);
|
||||
}
|
||||
@@ -1440,76 +1490,54 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
private async runGlobalSseLoop(abortController: AbortController): Promise<void> {
|
||||
const globalEndpoint = `${this.baseUrl.replace(/\/+$/, '')}/global/event`;
|
||||
let attempt = 0;
|
||||
const RECONNECT_DELAY_MS = 250;
|
||||
const STREAM_YIELD_MS = 8;
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
};
|
||||
if (this.globalSseLastEventId) {
|
||||
headers['Last-Event-ID'] = this.globalSseLastEventId;
|
||||
}
|
||||
|
||||
const response = await fetch(globalEndpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
const result = await this.client.global.event({
|
||||
signal: abortController.signal,
|
||||
onSseError: (error: unknown) => {
|
||||
if ((error as Error)?.name === 'AbortError' || abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
this.notifyGlobalSseError(error);
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Global SSE connect failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
attempt = 0;
|
||||
this.globalSseIsConnected = true;
|
||||
if (!abortController.signal.aborted) {
|
||||
this.notifyGlobalSseOpen();
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let yielded = Date.now();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (abortController.signal.aborted) break;
|
||||
if (!value || value.length === 0) continue;
|
||||
for await (const event of result.stream) {
|
||||
if (abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
|
||||
const blocks = buffer.split('\n\n');
|
||||
buffer = blocks.pop() ?? '';
|
||||
const directory = typeof event.directory === 'string' && event.directory.length > 0
|
||||
? event.directory
|
||||
: 'global';
|
||||
const routed = this.normalizeRoutedSsePayload({
|
||||
directory,
|
||||
payload: event.payload,
|
||||
});
|
||||
if (!routed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
const parsed = this.parseSseBlock(block);
|
||||
if (!parsed) continue;
|
||||
if (parsed.id) {
|
||||
this.globalSseLastEventId = parsed.id;
|
||||
}
|
||||
|
||||
const routed = this.normalizeRoutedSsePayload(parsed.data);
|
||||
if (routed) {
|
||||
this.emitGlobalSseEvent(routed);
|
||||
}
|
||||
this.emitGlobalSseEvent(routed);
|
||||
if (Date.now() - yielded >= STREAM_YIELD_MS) {
|
||||
yielded = Date.now();
|
||||
await wait(0);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = buffer.trim();
|
||||
if (remaining && !abortController.signal.aborted) {
|
||||
const parsed = this.parseSseBlock(remaining);
|
||||
if (parsed?.id) {
|
||||
this.globalSseLastEventId = parsed.id;
|
||||
}
|
||||
const routed = parsed ? this.normalizeRoutedSsePayload(parsed.data) : null;
|
||||
if (routed) {
|
||||
this.emitGlobalSseEvent(routed);
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended; force reconnect.
|
||||
this.globalSseIsConnected = false;
|
||||
} catch (error: unknown) {
|
||||
this.globalSseIsConnected = false;
|
||||
@@ -1525,8 +1553,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
const delay = Math.min(3000 * Math.pow(2, attempt), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await wait(Math.min(RECONNECT_DELAY_MS * Math.max(attempt, 1), 2000));
|
||||
}
|
||||
|
||||
this.flushGlobalSseQueue();
|
||||
|
||||
@@ -312,21 +312,10 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply server-persisted message limit. Ignore stale legacy values.
|
||||
const STALE_LIMITS = new Set([90, 120, 180, 220]);
|
||||
if (typeof settings.messageLimit === 'number' && Number.isFinite(settings.messageLimit)) {
|
||||
if (!STALE_LIMITS.has(settings.messageLimit)) {
|
||||
store.setMessageLimit(settings.messageLimit);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof settings.queueModeEnabled === 'boolean' && settings.queueModeEnabled !== queueStore.queueModeEnabled) {
|
||||
queueStore.setQueueMode(settings.queueModeEnabled);
|
||||
}
|
||||
|
||||
if (typeof settings.showTextJustificationActivity === 'boolean' && settings.showTextJustificationActivity !== store.showTextJustificationActivity) {
|
||||
store.setShowTextJustificationActivity(settings.showTextJustificationActivity);
|
||||
}
|
||||
if (typeof settings.showDeletionDialog === 'boolean' && settings.showDeletionDialog !== store.showDeletionDialog) {
|
||||
store.setShowDeletionDialog(settings.showDeletionDialog);
|
||||
}
|
||||
@@ -365,18 +354,36 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
if (typeof settings.maxLastMessageLength === 'number' && Number.isFinite(settings.maxLastMessageLength)) {
|
||||
store.setMaxLastMessageLength(settings.maxLastMessageLength);
|
||||
}
|
||||
if (typeof settings.toolCallExpansion === 'string'
|
||||
&& (settings.toolCallExpansion === 'collapsed'
|
||||
|| settings.toolCallExpansion === 'activity'
|
||||
|| settings.toolCallExpansion === 'detailed'
|
||||
|| settings.toolCallExpansion === 'changes')) {
|
||||
if (settings.toolCallExpansion !== store.toolCallExpansion) {
|
||||
store.setToolCallExpansion(settings.toolCallExpansion);
|
||||
}
|
||||
}
|
||||
if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) {
|
||||
store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled);
|
||||
}
|
||||
if (typeof settings.showToolFileIcons === 'boolean' && settings.showToolFileIcons !== store.showToolFileIcons) {
|
||||
store.setShowToolFileIcons(settings.showToolFileIcons);
|
||||
}
|
||||
if (typeof settings.showExpandedBashTools === 'boolean' && settings.showExpandedBashTools !== store.showExpandedBashTools) {
|
||||
store.setShowExpandedBashTools(settings.showExpandedBashTools);
|
||||
}
|
||||
if (typeof settings.showExpandedEditTools === 'boolean' && settings.showExpandedEditTools !== store.showExpandedEditTools) {
|
||||
store.setShowExpandedEditTools(settings.showExpandedEditTools);
|
||||
}
|
||||
if (typeof settings.chatRenderMode === 'string'
|
||||
&& (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
|
||||
if (settings.chatRenderMode !== store.chatRenderMode) {
|
||||
store.setChatRenderMode(settings.chatRenderMode);
|
||||
}
|
||||
}
|
||||
if (typeof settings.activityRenderMode === 'string'
|
||||
&& (settings.activityRenderMode === 'collapsed' || settings.activityRenderMode === 'summary')) {
|
||||
if (settings.activityRenderMode !== store.activityRenderMode) {
|
||||
store.setActivityRenderMode(settings.activityRenderMode);
|
||||
}
|
||||
}
|
||||
if (typeof settings.mermaidRenderingMode === 'string'
|
||||
&& (settings.mermaidRenderingMode === 'svg' || settings.mermaidRenderingMode === 'ascii')) {
|
||||
if (settings.mermaidRenderingMode !== store.mermaidRenderingMode) {
|
||||
store.setMermaidRenderingMode(settings.mermaidRenderingMode);
|
||||
}
|
||||
}
|
||||
if (typeof settings.userMessageRenderingMode === 'string'
|
||||
&& (settings.userMessageRenderingMode === 'markdown' || settings.userMessageRenderingMode === 'plain')) {
|
||||
if (settings.userMessageRenderingMode !== store.userMessageRenderingMode) {
|
||||
@@ -576,9 +583,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.queueModeEnabled === 'boolean') {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
if (typeof candidate.showTextJustificationActivity === 'boolean') {
|
||||
result.showTextJustificationActivity = candidate.showTextJustificationActivity;
|
||||
}
|
||||
if (typeof candidate.showDeletionDialog === 'boolean') {
|
||||
result.showDeletionDialog = candidate.showDeletionDialog;
|
||||
}
|
||||
@@ -747,18 +751,30 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof candidate.toolCallExpansion === 'string'
|
||||
&& (candidate.toolCallExpansion === 'collapsed'
|
||||
|| candidate.toolCallExpansion === 'activity'
|
||||
|| candidate.toolCallExpansion === 'detailed'
|
||||
|| candidate.toolCallExpansion === 'changes')
|
||||
) {
|
||||
result.toolCallExpansion = candidate.toolCallExpansion;
|
||||
}
|
||||
if (typeof candidate.inputSpellcheckEnabled === 'boolean') {
|
||||
result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled;
|
||||
}
|
||||
if (typeof candidate.showToolFileIcons === 'boolean') {
|
||||
result.showToolFileIcons = candidate.showToolFileIcons;
|
||||
}
|
||||
if (typeof candidate.showExpandedBashTools === 'boolean') {
|
||||
result.showExpandedBashTools = candidate.showExpandedBashTools;
|
||||
}
|
||||
if (typeof candidate.showExpandedEditTools === 'boolean') {
|
||||
result.showExpandedEditTools = candidate.showExpandedEditTools;
|
||||
}
|
||||
if (typeof candidate.chatRenderMode === 'string'
|
||||
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
|
||||
result.chatRenderMode = candidate.chatRenderMode;
|
||||
}
|
||||
if (typeof candidate.activityRenderMode === 'string'
|
||||
&& (candidate.activityRenderMode === 'collapsed' || candidate.activityRenderMode === 'summary')) {
|
||||
result.activityRenderMode = candidate.activityRenderMode;
|
||||
}
|
||||
if (typeof candidate.mermaidRenderingMode === 'string'
|
||||
&& (candidate.mermaidRenderingMode === 'svg' || candidate.mermaidRenderingMode === 'ascii')) {
|
||||
result.mermaidRenderingMode = candidate.mermaidRenderingMode;
|
||||
}
|
||||
if (typeof candidate.userMessageRenderingMode === 'string'
|
||||
&& (candidate.userMessageRenderingMode === 'markdown' || candidate.userMessageRenderingMode === 'plain')) {
|
||||
result.userMessageRenderingMode = candidate.userMessageRenderingMode;
|
||||
@@ -819,10 +835,6 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
result.pwaAppName = normalized.length > 0 ? normalized : '';
|
||||
}
|
||||
|
||||
if (typeof candidate.messageLimit === 'number' && Number.isFinite(candidate.messageLimit)) {
|
||||
result.messageLimit = candidate.messageLimit;
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#15141B",
|
||||
"foreground": "#EDECEE",
|
||||
"muted": "#1A1921",
|
||||
"mutedForeground": "#9c9393",
|
||||
"mutedForeground": "#8a8282",
|
||||
"elevated": "#201e2b",
|
||||
"elevatedForeground": "#EDECEE",
|
||||
"overlay": "#FFFFFF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#F5F0FF",
|
||||
"foreground": "#2D2640",
|
||||
"muted": "#EFE8FC",
|
||||
"mutedForeground": "#5C5270",
|
||||
"mutedForeground": "#807b8b",
|
||||
"elevated": "#f1ebfb",
|
||||
"elevatedForeground": "#2D2640",
|
||||
"overlay": "#15101F20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#0F1419",
|
||||
"foreground": "#D6DAE0",
|
||||
"muted": "#18222C",
|
||||
"mutedForeground": "#A3ADBA",
|
||||
"mutedForeground": "#777e86",
|
||||
"elevated": "#17202a",
|
||||
"elevatedForeground": "#D6DAE0",
|
||||
"overlay": "#FBFBFD20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FDFAF4",
|
||||
"foreground": "#394049",
|
||||
"muted": "#f7f3eb",
|
||||
"mutedForeground": "#6a727c",
|
||||
"mutedForeground": "#777e87",
|
||||
"elevated": "#faf6ed",
|
||||
"elevatedForeground": "#4F5964",
|
||||
"overlay": "#1B232B20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#161616",
|
||||
"foreground": "#F2F4F8",
|
||||
"muted": "#222222",
|
||||
"mutedForeground": "#b4b3b3",
|
||||
"mutedForeground": "#8b8a8a",
|
||||
"elevated": "#222222",
|
||||
"elevatedForeground": "#F2F4F8",
|
||||
"overlay": "#FFFFFF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#161616",
|
||||
"muted": "#F4F4F4",
|
||||
"mutedForeground": "#525252",
|
||||
"mutedForeground": "#868585",
|
||||
"elevated": "#f4f4f7",
|
||||
"elevatedForeground": "#161616",
|
||||
"overlay": "#00000020",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#1E1E2E",
|
||||
"foreground": "#CDD6F4",
|
||||
"muted": "#2a273b",
|
||||
"mutedForeground": "#c3cbea",
|
||||
"mutedForeground": "#969cb1",
|
||||
"elevated": "#282841",
|
||||
"elevatedForeground": "#CDD6F4",
|
||||
"overlay": "#F4F2FF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#fff6f4",
|
||||
"foreground": "#2e314a",
|
||||
"muted": "#f8ebe9",
|
||||
"mutedForeground": "#6C6F85",
|
||||
"mutedForeground": "#888992",
|
||||
"elevated": "#fbefea",
|
||||
"elevatedForeground": "#4C4F69",
|
||||
"overlay": "#1F1F2A20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#14151F",
|
||||
"foreground": "#F8F8F2",
|
||||
"muted": "#181926",
|
||||
"mutedForeground": "#B6B9E4",
|
||||
"mutedForeground": "#7c7e9c",
|
||||
"elevated": "#161722",
|
||||
"elevatedForeground": "#F8F8F2",
|
||||
"overlay": "#FFFFFF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#F8F8F2",
|
||||
"foreground": "#1F1F2F",
|
||||
"muted": "#eff1eb",
|
||||
"mutedForeground": "#52526B",
|
||||
"mutedForeground": "#6c6c7a",
|
||||
"elevated": "#eeeee5",
|
||||
"elevatedForeground": "#1F1F2F",
|
||||
"overlay": "#05040C20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#151313",
|
||||
"foreground": "#CECDC3",
|
||||
"muted": "#1C1B1A",
|
||||
"mutedForeground": "#878580",
|
||||
"mutedForeground": "#807e79",
|
||||
"elevated": "#1C1A19",
|
||||
"elevatedForeground": "#CECDC3",
|
||||
"overlay": "#00000080",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FFFCF0",
|
||||
"foreground": "#100F0F",
|
||||
"muted": "#F2F0E5",
|
||||
"mutedForeground": "#474643",
|
||||
"mutedForeground": "#686663",
|
||||
"elevated": "#fdf6ec",
|
||||
"elevatedForeground": "#100F0F",
|
||||
"overlay": "#100F0F20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#282828",
|
||||
"foreground": "#EBDBB2",
|
||||
"muted": "#32302F",
|
||||
"mutedForeground": "#A89984",
|
||||
"mutedForeground": "#918574",
|
||||
"elevated": "#25292b",
|
||||
"elevatedForeground": "#EBDBB2",
|
||||
"overlay": "#FBF1C720",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#1F1F28",
|
||||
"foreground": "#DCD7BA",
|
||||
"muted": "#23232d",
|
||||
"mutedForeground": "#807f78",
|
||||
"mutedForeground": "#7e7e77",
|
||||
"elevated": "#2A2A37",
|
||||
"elevatedForeground": "#DCD7BA",
|
||||
"overlay": "#16161DCC",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#F2ECBC",
|
||||
"foreground": "#545464",
|
||||
"muted": "#E7DBA0",
|
||||
"mutedForeground": "#717169",
|
||||
"mutedForeground": "#6c6c67",
|
||||
"elevated": "#eae4bf",
|
||||
"elevatedForeground": "#545464",
|
||||
"overlay": "#54546433",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"subtle": "#1A1A1A"
|
||||
},
|
||||
"interactive": {
|
||||
"border": "#222222",
|
||||
"border": "#454545",
|
||||
"borderHover": "#333333",
|
||||
"borderFocus": "#FFFFFF",
|
||||
"selection": "#ffffff1f",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#1A1A1A",
|
||||
"muted": "#F5F5F5",
|
||||
"mutedForeground": "#666666",
|
||||
"mutedForeground": "#757575",
|
||||
"elevated": "#FAFAFA",
|
||||
"elevatedForeground": "#1A1A1A",
|
||||
"overlay": "#00000020",
|
||||
@@ -131,7 +131,7 @@
|
||||
"link": "#333333",
|
||||
"linkHover": "#000000",
|
||||
"inlineCode": "#4D4D4D",
|
||||
"inlineCodeBackground": "#E5E5E5",
|
||||
"inlineCodeBackground": "#f1f1f1",
|
||||
"blockquote": "#808080",
|
||||
"blockquoteBorder": "#D9D9D9",
|
||||
"listMarker": "#66666699"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"subtle": "#1A1A1A"
|
||||
},
|
||||
"interactive": {
|
||||
"border": "#222222",
|
||||
"border": "#454545",
|
||||
"borderHover": "#333333",
|
||||
"borderFocus": "#FFFFFF",
|
||||
"selection": "#ffffff1f",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FFFFFF",
|
||||
"foreground": "#1A1A1A",
|
||||
"muted": "#F5F5F5",
|
||||
"mutedForeground": "#666666",
|
||||
"mutedForeground": "#6f6f6f",
|
||||
"elevated": "#FAFAFA",
|
||||
"elevatedForeground": "#1A1A1A",
|
||||
"overlay": "#00000020",
|
||||
@@ -110,7 +110,7 @@
|
||||
"link": "#333333",
|
||||
"linkHover": "#4a6a9e",
|
||||
"inlineCode": "#4a6a9e",
|
||||
"inlineCodeBackground": "#f1efef",
|
||||
"inlineCodeBackground": "#f1f1f1",
|
||||
"blockquote": "#808080",
|
||||
"blockquoteBorder": "#D9D9D9",
|
||||
"listMarker": "#66666699"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#23241E",
|
||||
"foreground": "#F8F8F2",
|
||||
"muted": "#282a20",
|
||||
"mutedForeground": "#C5C5C0",
|
||||
"mutedForeground": "#939390",
|
||||
"elevated": "#21221a",
|
||||
"elevatedForeground": "#F8F8F2",
|
||||
"overlay": "#FFFFFF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FDF8EC",
|
||||
"foreground": "#292318",
|
||||
"muted": "#F8F2E6",
|
||||
"mutedForeground": "#6D5C40",
|
||||
"mutedForeground": "#887c6a",
|
||||
"elevated": "#FBF5E8",
|
||||
"elevatedForeground": "#292318",
|
||||
"overlay": "#1C150C20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#011627",
|
||||
"foreground": "#D6DEEB",
|
||||
"muted": "#0B253A",
|
||||
"mutedForeground": "#7a9ebc",
|
||||
"mutedForeground": "#7d8892",
|
||||
"elevated": "#001122",
|
||||
"elevatedForeground": "#D6DEEB",
|
||||
"overlay": "#FFFFFF20",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"background": "#FBFBFB",
|
||||
"foreground": "#403F53",
|
||||
"muted": "#F0F0F0",
|
||||
"mutedForeground": "#555a5a",
|
||||
"mutedForeground": "#747a7a",
|
||||
"elevated": "#FFFFFF",
|
||||
"elevatedForeground": "#403F53",
|
||||
"overlay": "#1A1A1A20",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user