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
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user