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

* refactor: add canonical turn projection pipeline for chat

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

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

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

* refactor: complete phase-4 turn pipeline cutover

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

* fix: align chat history loading path with opencode parity

* fix: stabilize chat streaming and reduce render churn

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

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

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

* fix: stabilize activity stream and tool progress rendering

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

* fix: reverted regression of revert functionality

* fix: stabilize chat activity layout and tool progress behavior

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

* fix: restore activity mode behavior and stabilize tool rendering

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

* refactor: simplify chat tool and message rendering

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

* style: narrow chat and composer columns equally

* fix: Tool status checks and abort flow regression

* fix: improve multi-file apply patch header rendering

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

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

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

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

* fix: keep composer status row closest to the input

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

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

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

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

* feat: add sorted chat mode with progressive activity rendering

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

* fix: stabilize chat timeline navigation and older history loading

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

* fix: stabilize sorted activity text and hide reasoning timers

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

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

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

* feat: enhance event stream handling for message parts

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

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

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

refactor: remove unused streaming part handling in messageStore

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

chore: clean up unused streaming text normalization functions

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

* refactor: simplify streaming event passthrough

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

* revert: remove message memory settings UI

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

* feat: add compact collapsed Activity preview in sorted mode

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

* fix: adjusted activity changed files text prominence

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

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

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

* refactor: align web and vscode session activity streaming

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

* fix: adjusted muted foreground themes color

* fix: stabilize session activity and attention updates

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

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

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

* fix: update default GitHub OAuth client ID

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

* fix: sync chat settings across VS Code chat views

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

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

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

* style: polish expanded tool panels and bash output readability

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

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

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

* fix: align chat loading skeleton with message layout

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

* fix: prevent blank screen when adding inline diff comments

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

* fix: streamline VS Code archived sessions sidebar

Show archived sessions in VS Code only for the active workspace directory.
Render archived sessions as a flat list in VS Code without project-root subfolders.
Hide the "Open in Side Panel" session action in VS Code.
This commit is contained in:
Bohdan Triapitsyn
2026-03-12 23:45:45 +02:00
committed by GitHub
parent d969ff4af1
commit fab32ca992
134 changed files with 9366 additions and 6147 deletions
@@ -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,
};
};