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,47 @@
|
||||
interface SessionLinkRecord {
|
||||
id: string;
|
||||
parentID?: string;
|
||||
}
|
||||
|
||||
export const collectVisibleSessionIdsForBlockingRequests = (
|
||||
sessions: SessionLinkRecord[] | undefined,
|
||||
currentSessionId: string | null,
|
||||
): string[] => {
|
||||
if (!currentSessionId) return [];
|
||||
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
|
||||
|
||||
const current = sessions.find((session) => session.id === currentSessionId);
|
||||
if (!current) return [currentSessionId];
|
||||
|
||||
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
|
||||
if (current.parentID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const childIds = sessions
|
||||
.filter((session) => session.parentID === currentSessionId)
|
||||
.map((session) => session.id);
|
||||
|
||||
return [currentSessionId, ...childIds];
|
||||
};
|
||||
|
||||
export const flattenBlockingRequests = <T extends { id: string }>(
|
||||
source: Map<string, T[]>,
|
||||
sessionIds: string[],
|
||||
): T[] => {
|
||||
if (sessionIds.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const entries = source.get(sessionId);
|
||||
if (!entries || entries.length === 0) continue;
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue;
|
||||
seen.add(entry.id);
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
export const normalizeWheelDelta = (input: {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
rootHeight?: number;
|
||||
}): number => {
|
||||
if (input.deltaMode === 1) {
|
||||
return input.deltaY * 40;
|
||||
}
|
||||
if (input.deltaMode === 2) {
|
||||
return input.deltaY * (input.rootHeight ?? 120);
|
||||
}
|
||||
return input.deltaY;
|
||||
};
|
||||
|
||||
export const shouldMarkBoundaryGesture = (input: {
|
||||
delta: number;
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}): boolean => {
|
||||
const max = input.scrollHeight - input.clientHeight;
|
||||
if (max <= 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!input.delta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (input.delta < 0) {
|
||||
return input.scrollTop + input.delta <= 0;
|
||||
}
|
||||
|
||||
const remaining = max - input.scrollTop;
|
||||
return input.delta > remaining;
|
||||
};
|
||||
|
||||
export const boundaryTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement => {
|
||||
const current = target instanceof Element ? target : undefined;
|
||||
const nested = current?.closest('[data-scrollable]');
|
||||
if (!nested || nested === root) {
|
||||
return root;
|
||||
}
|
||||
if (!(nested instanceof HTMLElement)) {
|
||||
return root;
|
||||
}
|
||||
return nested;
|
||||
};
|
||||
|
||||
export const shouldPauseAutoScrollOnWheel = (input: {
|
||||
root: HTMLElement;
|
||||
target: EventTarget | null;
|
||||
delta: number;
|
||||
}): boolean => {
|
||||
if (input.delta >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = boundaryTarget(input.root, input.target);
|
||||
if (target === input.root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return shouldMarkBoundaryGesture({
|
||||
delta: input.delta,
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
});
|
||||
};
|
||||
|
||||
export const isNearTop = (scrollTop: number, threshold: number): boolean => {
|
||||
return scrollTop <= threshold;
|
||||
};
|
||||
|
||||
export const isNearBottom = (distanceFromBottom: number, threshold: number): boolean => {
|
||||
return distanceFromBottom <= threshold;
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
export type VisibleTurn = {
|
||||
id: string;
|
||||
ratio: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export type OffsetTurn = {
|
||||
id: string;
|
||||
top: number;
|
||||
};
|
||||
|
||||
type ScrollSpyInput = {
|
||||
onActive: (id: string) => void;
|
||||
raf?: (cb: FrameRequestCallback) => number;
|
||||
caf?: (id: number) => void;
|
||||
IntersectionObserver?: typeof globalThis.IntersectionObserver;
|
||||
ResizeObserver?: typeof globalThis.ResizeObserver;
|
||||
MutationObserver?: typeof globalThis.MutationObserver;
|
||||
};
|
||||
|
||||
export const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
if (b.ratio !== a.ratio) {
|
||||
return b.ratio - a.ratio;
|
||||
}
|
||||
|
||||
const distanceA = Math.abs(a.top - line);
|
||||
const distanceB = Math.abs(b.top - line);
|
||||
if (distanceA !== distanceB) {
|
||||
return distanceA - distanceB;
|
||||
}
|
||||
|
||||
return a.top - b.top;
|
||||
});
|
||||
|
||||
return sorted[0]?.id;
|
||||
};
|
||||
|
||||
export const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||
if (list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = list.length - 1;
|
||||
let out = 0;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
const top = list[mid]?.top;
|
||||
if (top === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (top <= cutoff) {
|
||||
out = mid;
|
||||
lo = mid + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
hi = mid - 1;
|
||||
}
|
||||
|
||||
return list[out]?.id;
|
||||
};
|
||||
|
||||
export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||
const raf = input.raf ?? requestAnimationFrame;
|
||||
const caf = input.caf ?? cancelAnimationFrame;
|
||||
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver;
|
||||
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
|
||||
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
|
||||
|
||||
let root: HTMLDivElement | undefined;
|
||||
let io: IntersectionObserver | undefined;
|
||||
let ro: ResizeObserver | undefined;
|
||||
let mo: MutationObserver | undefined;
|
||||
let frame: number | undefined;
|
||||
let active: string | undefined;
|
||||
let dirty = true;
|
||||
|
||||
const nodes = new Map<string, HTMLElement>();
|
||||
const idByElement = new WeakMap<HTMLElement, string>();
|
||||
const visible = new Map<string, { ratio: number; top: number }>();
|
||||
let offsets: OffsetTurn[] = [];
|
||||
|
||||
const schedule = () => {
|
||||
if (frame !== undefined) {
|
||||
return;
|
||||
}
|
||||
frame = raf(() => {
|
||||
frame = undefined;
|
||||
update();
|
||||
});
|
||||
};
|
||||
|
||||
const refreshOffsets = () => {
|
||||
const container = root;
|
||||
if (!container) {
|
||||
offsets = [];
|
||||
dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTop = container.getBoundingClientRect().top;
|
||||
offsets = [...nodes].map(([key, element]) => ({
|
||||
id: key,
|
||||
top: element.getBoundingClientRect().top - baseTop + container.scrollTop,
|
||||
}));
|
||||
offsets.sort((a, b) => a.top - b.top);
|
||||
dirty = false;
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
const container = root;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const line = container.getBoundingClientRect().top + 100;
|
||||
const next =
|
||||
pickVisibleTurnId(
|
||||
[...visible].map(([id, value]) => ({
|
||||
id,
|
||||
ratio: value.ratio,
|
||||
top: value.top,
|
||||
})),
|
||||
line,
|
||||
)
|
||||
?? (() => {
|
||||
if (dirty) {
|
||||
refreshOffsets();
|
||||
}
|
||||
return pickOffsetTurnId(offsets, container.scrollTop + 100);
|
||||
})();
|
||||
|
||||
if (!next || next === active) {
|
||||
return;
|
||||
}
|
||||
|
||||
active = next;
|
||||
input.onActive(next);
|
||||
};
|
||||
|
||||
const observe = () => {
|
||||
const container = root;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
io?.disconnect();
|
||||
io = undefined;
|
||||
if (CtorIO) {
|
||||
try {
|
||||
io = new CtorIO(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target;
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = idByElement.get(element);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
|
||||
visible.delete(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
visible.set(key, {
|
||||
ratio: entry.intersectionRatio,
|
||||
top: entry.boundingClientRect.top,
|
||||
});
|
||||
}
|
||||
|
||||
schedule();
|
||||
},
|
||||
{
|
||||
root: container,
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
io = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (io) {
|
||||
for (const element of nodes.values()) {
|
||||
io.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
ro?.disconnect();
|
||||
ro = undefined;
|
||||
if (CtorRO) {
|
||||
ro = new CtorRO(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
ro.observe(container);
|
||||
for (const element of nodes.values()) {
|
||||
ro.observe(element);
|
||||
}
|
||||
}
|
||||
|
||||
mo?.disconnect();
|
||||
mo = undefined;
|
||||
if (CtorMO) {
|
||||
mo = new CtorMO(() => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
mo.observe(container, { subtree: true, childList: true, characterData: true });
|
||||
}
|
||||
|
||||
dirty = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const setContainer = (element?: HTMLDivElement) => {
|
||||
if (root === element) {
|
||||
return;
|
||||
}
|
||||
|
||||
root = element;
|
||||
visible.clear();
|
||||
active = undefined;
|
||||
observe();
|
||||
};
|
||||
|
||||
const register = (element: HTMLElement, key: string) => {
|
||||
const previous = nodes.get(key);
|
||||
if (previous && previous !== element) {
|
||||
io?.unobserve(previous);
|
||||
ro?.unobserve(previous);
|
||||
}
|
||||
|
||||
nodes.set(key, element);
|
||||
idByElement.set(element, key);
|
||||
if (io) {
|
||||
io.observe(element);
|
||||
}
|
||||
if (ro) {
|
||||
ro.observe(element);
|
||||
}
|
||||
dirty = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const unregister = (key: string) => {
|
||||
const element = nodes.get(key);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
io?.unobserve(element);
|
||||
ro?.unobserve(element);
|
||||
nodes.delete(key);
|
||||
visible.delete(key);
|
||||
dirty = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const markDirty = () => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
for (const element of nodes.values()) {
|
||||
io?.unobserve(element);
|
||||
ro?.unobserve(element);
|
||||
}
|
||||
|
||||
nodes.clear();
|
||||
visible.clear();
|
||||
offsets = [];
|
||||
active = undefined;
|
||||
dirty = true;
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
if (frame !== undefined) {
|
||||
caf(frame);
|
||||
}
|
||||
frame = undefined;
|
||||
clear();
|
||||
io?.disconnect();
|
||||
ro?.disconnect();
|
||||
mo?.disconnect();
|
||||
io = undefined;
|
||||
ro = undefined;
|
||||
mo = undefined;
|
||||
root = undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
setContainer,
|
||||
register,
|
||||
unregister,
|
||||
onScroll: schedule,
|
||||
markDirty,
|
||||
clear,
|
||||
destroy,
|
||||
getActiveId: () => active,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Message } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import type { ChatMessageEntry } from './types';
|
||||
|
||||
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
|
||||
const info = message.info as { clientRole?: string | null; role?: string | null };
|
||||
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
||||
?? (typeof info.role === 'string' ? info.role : null)
|
||||
?? null;
|
||||
};
|
||||
|
||||
interface RetryOverlayInput {
|
||||
sessionId: string | null;
|
||||
message: string;
|
||||
confirmedAt?: number;
|
||||
fallbackTimestamp: number;
|
||||
}
|
||||
|
||||
export const applyRetryOverlay = (
|
||||
messages: ChatMessageEntry[],
|
||||
input: RetryOverlayInput,
|
||||
): ChatMessageEntry[] => {
|
||||
if (!input.sessionId) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
const retryError = {
|
||||
name: 'SessionRetry',
|
||||
message: input.message,
|
||||
data: { message: input.message },
|
||||
};
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (resolveMessageRole(messages[index]) === 'user') {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserIndex < 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let targetAssistantIndex = -1;
|
||||
for (let index = messages.length - 1; index > lastUserIndex; index -= 1) {
|
||||
if (resolveMessageRole(messages[index]) === 'assistant') {
|
||||
targetAssistantIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAssistantIndex >= 0) {
|
||||
const existing = messages[targetAssistantIndex];
|
||||
const existingInfo = existing.info as { error?: unknown };
|
||||
if (existingInfo.error) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((message, index) => {
|
||||
if (index !== targetAssistantIndex) {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
info: {
|
||||
...(message.info as Record<string, unknown>),
|
||||
error: retryError,
|
||||
} as unknown as Message,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const eventTime = typeof input.confirmedAt === 'number' ? input.confirmedAt : input.fallbackTimestamp;
|
||||
const syntheticId = `synthetic_retry_notice_${input.sessionId}`;
|
||||
const synthetic: ChatMessageEntry = {
|
||||
info: {
|
||||
id: syntheticId,
|
||||
sessionID: input.sessionId,
|
||||
role: 'assistant',
|
||||
time: { created: eventTime, completed: eventTime },
|
||||
finish: 'stop',
|
||||
error: retryError,
|
||||
} as unknown as Message,
|
||||
parts: [],
|
||||
};
|
||||
|
||||
const next = messages.slice();
|
||||
next.splice(lastUserIndex + 1, 0, synthetic);
|
||||
return next;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export const ACTIVITY_STANDALONE_TOOL_NAMES = new Set<string>(['task']);
|
||||
|
||||
export const HIDDEN_INTERNAL_TOOL_NAMES = new Set<string>(['todowrite', 'todoread']);
|
||||
|
||||
export const TURN_WINDOW_DEFAULTS = {
|
||||
initialTurns: 10,
|
||||
batchTurns: 8,
|
||||
prefetchBuffer: 16,
|
||||
} as const;
|
||||
|
||||
export const TURN_TEXT_THROTTLE_DEFAULT_MS = 100;
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { SessionMemoryState } from '@/stores/types/sessionTypes';
|
||||
|
||||
export interface TurnHistorySignalsInput {
|
||||
memoryState: SessionMemoryState | null;
|
||||
loadedMessageCount: number;
|
||||
loadedTurnCount: number;
|
||||
turnStart: number;
|
||||
defaultHistoryLimit: number;
|
||||
}
|
||||
|
||||
export interface TurnHistorySignals {
|
||||
hasBufferedTurns: boolean;
|
||||
hasMoreAboveTurns: boolean;
|
||||
historyLoading: boolean;
|
||||
canLoadEarlier: boolean;
|
||||
}
|
||||
|
||||
const deriveHasMoreAbove = (
|
||||
memoryState: SessionMemoryState | null,
|
||||
loadedMessageCount: number,
|
||||
loadedTurnCount: number,
|
||||
defaultHistoryLimit: number,
|
||||
): boolean => {
|
||||
if (!memoryState) {
|
||||
return loadedMessageCount >= defaultHistoryLimit;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === true || memoryState.hasMoreAbove === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.historyComplete === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (memoryState.hasMoreTurnsAbove === false || memoryState.hasMoreAbove === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackMessageSignal = loadedMessageCount >= defaultHistoryLimit;
|
||||
const fallbackTurnSignal = loadedTurnCount >= Math.max(1, Math.floor(defaultHistoryLimit / 2));
|
||||
return fallbackMessageSignal || fallbackTurnSignal;
|
||||
};
|
||||
|
||||
export const deriveTurnHistorySignals = (
|
||||
input: TurnHistorySignalsInput,
|
||||
): TurnHistorySignals => {
|
||||
const hasBufferedTurns = input.turnStart > 0;
|
||||
const hasMoreAboveTurns = deriveHasMoreAbove(
|
||||
input.memoryState,
|
||||
input.loadedMessageCount,
|
||||
input.loadedTurnCount,
|
||||
input.defaultHistoryLimit,
|
||||
);
|
||||
const historyLoading = Boolean(input.memoryState?.historyLoading);
|
||||
|
||||
return {
|
||||
hasBufferedTurns,
|
||||
hasMoreAboveTurns,
|
||||
historyLoading,
|
||||
canLoadEarlier: hasBufferedTurns || hasMoreAboveTurns,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,243 @@
|
||||
import { ACTIVITY_STANDALONE_TOOL_NAMES } from './constants';
|
||||
import type {
|
||||
ChatMessageEntry,
|
||||
TurnActivityGroup,
|
||||
TurnActivityRecord,
|
||||
TurnPartRecord,
|
||||
} from './types';
|
||||
|
||||
const isStandaloneTool = (toolName: unknown): boolean => {
|
||||
return typeof toolName === 'string' && ACTIVITY_STANDALONE_TOOL_NAMES.has(toolName.toLowerCase());
|
||||
};
|
||||
|
||||
const getPartEndTime = (part: unknown): number | undefined => {
|
||||
const stateEnd = (part as { state?: { time?: { end?: unknown } } }).state?.time?.end;
|
||||
if (typeof stateEnd === 'number') {
|
||||
return stateEnd;
|
||||
}
|
||||
const timeEnd = (part as { time?: { end?: unknown } }).time?.end;
|
||||
return typeof timeEnd === 'number' ? timeEnd : undefined;
|
||||
};
|
||||
|
||||
const getPartText = (part: unknown): string | undefined => {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
const content = (part as { content?: unknown }).content;
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
|
||||
const finish = (message.info as { finish?: unknown }).finish;
|
||||
return typeof finish === 'string' ? finish : undefined;
|
||||
};
|
||||
|
||||
const isAssistantMessageCompleted = (message: ChatMessageEntry): boolean => {
|
||||
const info = message.info as { time?: { completed?: unknown }; status?: unknown };
|
||||
const completed = info.time?.completed;
|
||||
const status = info.status;
|
||||
if (typeof completed !== 'number' || completed <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (typeof status === 'string') {
|
||||
return status === 'completed';
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const buildTurnPartRecord = (
|
||||
turnId: string,
|
||||
messageId: string,
|
||||
part: ChatMessageEntry['parts'][number],
|
||||
partIndex: number,
|
||||
): TurnPartRecord => {
|
||||
return {
|
||||
id: part.id ?? `${messageId}-part-${partIndex}-${part.type}`,
|
||||
turnId,
|
||||
messageId,
|
||||
part,
|
||||
partIndex,
|
||||
endedAt: getPartEndTime(part),
|
||||
};
|
||||
};
|
||||
|
||||
interface ProjectActivityInput {
|
||||
turnId: string;
|
||||
assistantMessages: ChatMessageEntry[];
|
||||
summarySourceMessageId?: string;
|
||||
showTextJustificationActivity: boolean;
|
||||
}
|
||||
|
||||
interface ProjectActivityResult {
|
||||
activityParts: TurnActivityRecord[];
|
||||
activitySegments: TurnActivityGroup[];
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
}
|
||||
|
||||
export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivityResult => {
|
||||
const activityParts: TurnActivityRecord[] = [];
|
||||
const hasTools = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'tool')
|
||||
);
|
||||
const hasReasoning = input.assistantMessages.some((message) =>
|
||||
message.parts.some((part) => part.type === 'reasoning' && Boolean(getPartText(part)))
|
||||
);
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'tool',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'reasoning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.showTextJustificationActivity || part.type !== 'text') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAssistantMessageCompleted(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finish = getMessageFinish(message);
|
||||
if (!finish) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (finish === 'stop') {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getPartText(part);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityParts.push({
|
||||
...buildTurnPartRecord(input.turnId, message.info.id, part, partIndex),
|
||||
kind: 'justification',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const activitySegments: TurnActivityGroup[] = [];
|
||||
const activityByPart = new WeakMap<object, TurnActivityRecord>();
|
||||
activityParts.forEach((activity) => {
|
||||
activityByPart.set(activity.part as object, activity);
|
||||
});
|
||||
|
||||
const taskMessageById = new Map<string, string>();
|
||||
const taskOrder: string[] = [];
|
||||
const partsByAfterTool = new Map<string | null, TurnActivityRecord[]>();
|
||||
let currentAfterToolPartId: string | null = null;
|
||||
|
||||
input.assistantMessages.forEach((message) => {
|
||||
const messageId = message.info.id;
|
||||
|
||||
message.parts.forEach((part, partIndex) => {
|
||||
if (part.type === 'tool') {
|
||||
const toolName = (part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
const toolPartId = part.id ?? `${messageId}-part-${partIndex}-${part.type}`;
|
||||
if (!taskMessageById.has(toolPartId)) {
|
||||
taskMessageById.set(toolPartId, messageId);
|
||||
taskOrder.push(toolPartId);
|
||||
}
|
||||
currentAfterToolPartId = toolPartId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const activity = activityByPart.get(part as object);
|
||||
if (!activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activity.kind === 'tool') {
|
||||
const toolName = (activity.part as { tool?: unknown }).tool;
|
||||
if (isStandaloneTool(toolName)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const list = partsByAfterTool.get(currentAfterToolPartId) ?? [];
|
||||
list.push(activity);
|
||||
partsByAfterTool.set(currentAfterToolPartId, list);
|
||||
});
|
||||
});
|
||||
|
||||
const pickStartAnchor = (segmentParts: TurnActivityRecord[]): string | undefined => {
|
||||
if (segmentParts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const countByMessage = new Map<string, number>();
|
||||
segmentParts.forEach((activity) => {
|
||||
countByMessage.set(activity.messageId, (countByMessage.get(activity.messageId) ?? 0) + 1);
|
||||
});
|
||||
|
||||
let firstWithAny: string | undefined;
|
||||
let cumulative = 0;
|
||||
for (const message of input.assistantMessages) {
|
||||
const count = countByMessage.get(message.info.id) ?? 0;
|
||||
if (count > 0 && !firstWithAny) {
|
||||
firstWithAny = message.info.id;
|
||||
}
|
||||
cumulative += count;
|
||||
if (cumulative >= 2) {
|
||||
return message.info.id;
|
||||
}
|
||||
}
|
||||
|
||||
return firstWithAny;
|
||||
};
|
||||
|
||||
const orderedKeys: Array<string | null> = [null, ...taskOrder];
|
||||
orderedKeys.forEach((afterToolPartId) => {
|
||||
const segmentParts = partsByAfterTool.get(afterToolPartId) ?? [];
|
||||
if (segmentParts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorMessageId = afterToolPartId === null
|
||||
? pickStartAnchor(segmentParts)
|
||||
: taskMessageById.get(afterToolPartId);
|
||||
|
||||
if (!anchorMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
activitySegments.push({
|
||||
id: `${input.turnId}:${anchorMessageId}:${afterToolPartId ?? 'start'}`,
|
||||
anchorMessageId,
|
||||
afterToolPartId,
|
||||
parts: segmentParts,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
activityParts,
|
||||
activitySegments,
|
||||
hasTools,
|
||||
hasReasoning,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TurnIndexes, TurnProjectionResult, TurnRecord } from './types';
|
||||
|
||||
export const projectTurnIndexes = (turns: TurnRecord[]): TurnProjectionResult => {
|
||||
const turnById = new Map<string, TurnRecord>();
|
||||
const messageToTurnId = new Map<string, string>();
|
||||
const messageMetaById: TurnIndexes['messageMetaById'] = new Map();
|
||||
const groupedMessageIds = new Set<string>();
|
||||
|
||||
turns.forEach((turn) => {
|
||||
turnById.set(turn.turnId, turn);
|
||||
|
||||
const firstAssistantId = turn.assistantMessages[0]?.info.id;
|
||||
const lastAssistantId = turn.assistantMessages[turn.assistantMessages.length - 1]?.info.id;
|
||||
|
||||
messageToTurnId.set(turn.userMessageId, turn.turnId);
|
||||
groupedMessageIds.add(turn.userMessageId);
|
||||
messageMetaById.set(turn.userMessageId, {
|
||||
turnId: turn.turnId,
|
||||
messageId: turn.userMessageId,
|
||||
userMessageId: turn.userMessageId,
|
||||
isUserMessage: true,
|
||||
isAssistantMessage: false,
|
||||
isFirstAssistantInTurn: false,
|
||||
isLastAssistantInTurn: false,
|
||||
headerMessageId: turn.headerMessageId,
|
||||
});
|
||||
|
||||
turn.assistantMessageIds.forEach((assistantMessageId) => {
|
||||
messageToTurnId.set(assistantMessageId, turn.turnId);
|
||||
groupedMessageIds.add(assistantMessageId);
|
||||
messageMetaById.set(assistantMessageId, {
|
||||
turnId: turn.turnId,
|
||||
messageId: assistantMessageId,
|
||||
userMessageId: turn.userMessageId,
|
||||
isUserMessage: false,
|
||||
isAssistantMessage: true,
|
||||
isFirstAssistantInTurn: assistantMessageId === firstAssistantId,
|
||||
isLastAssistantInTurn: assistantMessageId === lastAssistantId,
|
||||
headerMessageId: turn.headerMessageId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
|
||||
const lastTurnMessageIds = new Set<string>();
|
||||
if (lastTurn) {
|
||||
lastTurnMessageIds.add(lastTurn.userMessageId);
|
||||
lastTurn.assistantMessageIds.forEach((messageId) => {
|
||||
lastTurnMessageIds.add(messageId);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
indexes: {
|
||||
turnById,
|
||||
messageToTurnId,
|
||||
messageMetaById,
|
||||
},
|
||||
lastTurnId: lastTurn?.turnId ?? null,
|
||||
lastTurnMessageIds,
|
||||
ungroupedMessageIds: new Set<string>(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import { projectTurnActivity } from './projectTurnActivity';
|
||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||
import { projectTurnDiffStats, projectTurnSummary } from './projectTurnSummary';
|
||||
import type {
|
||||
ChatMessageEntry,
|
||||
TurnMessageRecord,
|
||||
TurnProjectionResult,
|
||||
TurnRecord,
|
||||
TurnStreamState,
|
||||
} from './types';
|
||||
|
||||
const resolveMessageRole = (message: ChatMessageEntry): string => {
|
||||
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role;
|
||||
return typeof role === 'string' ? role : '';
|
||||
};
|
||||
|
||||
const getMessageParentId = (message: ChatMessageEntry): string | undefined => {
|
||||
const parentId = (message.info as { parentID?: unknown }).parentID;
|
||||
if (typeof parentId !== 'string' || parentId.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parentId;
|
||||
};
|
||||
|
||||
const getMessageCreatedAt = (message: ChatMessageEntry): number | undefined => {
|
||||
const created = (message.info as { time?: { created?: unknown } }).time?.created;
|
||||
return typeof created === 'number' ? created : undefined;
|
||||
};
|
||||
|
||||
const getMessageCompletedAt = (message: ChatMessageEntry): number | undefined => {
|
||||
const completed = (message.info as { time?: { completed?: unknown } }).time?.completed;
|
||||
return typeof completed === 'number' ? completed : undefined;
|
||||
};
|
||||
|
||||
const getMessageFinish = (message: ChatMessageEntry): string | undefined => {
|
||||
const finish = (message.info as { finish?: unknown }).finish;
|
||||
return typeof finish === 'string' ? finish : undefined;
|
||||
};
|
||||
|
||||
const getMessageStatus = (message: ChatMessageEntry): string | undefined => {
|
||||
const status = (message.info as { status?: unknown }).status;
|
||||
return typeof status === 'string' ? status : undefined;
|
||||
};
|
||||
|
||||
const getPartText = (part: ChatMessageEntry['parts'][number]): string | undefined => {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text === 'string') {
|
||||
return text;
|
||||
}
|
||||
const content = (part as { content?: unknown }).content;
|
||||
return typeof content === 'string' ? content : undefined;
|
||||
};
|
||||
|
||||
const arePartsEquivalentForReuse = (
|
||||
previousPart: ChatMessageEntry['parts'][number],
|
||||
nextPart: ChatMessageEntry['parts'][number],
|
||||
): boolean => {
|
||||
if (previousPart === nextPart) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previousPart.type !== nextPart.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousPart.id && nextPart.id && previousPart.id !== nextPart.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousPart.type === 'text' || previousPart.type === 'reasoning') {
|
||||
return getPartText(previousPart) === getPartText(nextPart);
|
||||
}
|
||||
|
||||
if (previousPart.type === 'tool') {
|
||||
const previousTool = previousPart as {
|
||||
tool?: unknown;
|
||||
callID?: unknown;
|
||||
state?: { status?: unknown };
|
||||
};
|
||||
const nextTool = nextPart as {
|
||||
tool?: unknown;
|
||||
callID?: unknown;
|
||||
state?: { status?: unknown };
|
||||
};
|
||||
|
||||
return previousTool.tool === nextTool.tool
|
||||
&& previousTool.callID === nextTool.callID
|
||||
&& previousTool.state?.status === nextTool.state?.status;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const areMessagesEquivalentForReuse = (previousMessage: ChatMessageEntry, nextMessage: ChatMessageEntry): boolean => {
|
||||
if (previousMessage === nextMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previousMessage.info.id !== nextMessage.info.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getMessageCompletedAt(previousMessage) !== getMessageCompletedAt(nextMessage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getMessageFinish(previousMessage) !== getMessageFinish(nextMessage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getMessageStatus(previousMessage) !== getMessageStatus(nextMessage)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousMessage.parts.length !== nextMessage.parts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < previousMessage.parts.length; index += 1) {
|
||||
if (!arePartsEquivalentForReuse(previousMessage.parts[index], nextMessage.parts[index])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getUserSummaryBody = (message: ChatMessageEntry): string | undefined => {
|
||||
const summaryBody = (message.info as { summary?: { body?: unknown } | null | undefined })?.summary?.body;
|
||||
if (typeof summaryBody !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = summaryBody.trim();
|
||||
return trimmed.length > 0 ? summaryBody : undefined;
|
||||
};
|
||||
|
||||
const createTurnMessageRecord = (message: ChatMessageEntry, order: number): TurnMessageRecord => {
|
||||
const role = resolveMessageRole(message);
|
||||
return {
|
||||
messageId: message.info.id,
|
||||
role,
|
||||
parentMessageId: getMessageParentId(message),
|
||||
message,
|
||||
order,
|
||||
};
|
||||
};
|
||||
|
||||
const buildTurnStreamState = (userMessage: ChatMessageEntry, assistantMessages: ChatMessageEntry[]): TurnStreamState => {
|
||||
const startedAt = getMessageCreatedAt(userMessage);
|
||||
let completedAt: number | undefined;
|
||||
let isStreaming = false;
|
||||
|
||||
assistantMessages.forEach((message) => {
|
||||
const completed = getMessageCompletedAt(message);
|
||||
if (typeof completed === 'number') {
|
||||
completedAt = Math.max(completedAt ?? 0, completed);
|
||||
} else {
|
||||
isStreaming = true;
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = typeof startedAt === 'number' && typeof completedAt === 'number' && completedAt >= startedAt
|
||||
? completedAt - startedAt
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
isRetrying: assistantMessages.length > 1,
|
||||
startedAt,
|
||||
completedAt,
|
||||
durationMs,
|
||||
};
|
||||
};
|
||||
|
||||
interface ProjectTurnRecordsOptions {
|
||||
previousProjection?: TurnProjectionResult | null;
|
||||
showTextJustificationActivity: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ProjectTurnRecordsOptions = {
|
||||
previousProjection: null,
|
||||
showTextJustificationActivity: false,
|
||||
};
|
||||
|
||||
export const projectTurnRecords = (
|
||||
messages: ChatMessageEntry[],
|
||||
options?: Partial<ProjectTurnRecordsOptions>,
|
||||
): TurnProjectionResult => {
|
||||
const effectiveOptions: ProjectTurnRecordsOptions = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...options,
|
||||
};
|
||||
|
||||
const turns: TurnRecord[] = [];
|
||||
const turnByUserId = new Map<string, TurnRecord>();
|
||||
const previousTurnsById = new Map((effectiveOptions.previousProjection?.turns ?? []).map((turn) => [turn.turnId, turn]));
|
||||
const groupedMessageIds = new Set<string>();
|
||||
let currentTurn: TurnRecord | undefined;
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
const role = resolveMessageRole(message);
|
||||
if (role === 'user') {
|
||||
const turnId = message.info.id;
|
||||
const turn: TurnRecord = {
|
||||
turnId,
|
||||
userMessageId: message.info.id,
|
||||
userMessage: message,
|
||||
headerMessageId: undefined,
|
||||
messages: [createTurnMessageRecord(message, index)],
|
||||
assistantMessageIds: [],
|
||||
assistantMessages: [],
|
||||
activityParts: [],
|
||||
activitySegments: [],
|
||||
summary: {},
|
||||
summaryText: undefined,
|
||||
hasTools: false,
|
||||
hasReasoning: false,
|
||||
diffStats: undefined,
|
||||
stream: {
|
||||
isStreaming: false,
|
||||
isRetrying: false,
|
||||
},
|
||||
};
|
||||
turns.push(turn);
|
||||
turnByUserId.set(turn.userMessageId, turn);
|
||||
groupedMessageIds.add(message.info.id);
|
||||
currentTurn = turn;
|
||||
return;
|
||||
}
|
||||
|
||||
if (role !== 'assistant') {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentId = getMessageParentId(message);
|
||||
const parentTurn = parentId ? turnByUserId.get(parentId) : undefined;
|
||||
const targetTurn = parentTurn ?? currentTurn;
|
||||
if (!targetTurn) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetTurn.assistantMessages.push(message);
|
||||
targetTurn.assistantMessageIds.push(message.info.id);
|
||||
targetTurn.messages.push(createTurnMessageRecord(message, index));
|
||||
if (!targetTurn.headerMessageId) {
|
||||
targetTurn.headerMessageId = message.info.id;
|
||||
}
|
||||
groupedMessageIds.add(message.info.id);
|
||||
|
||||
if (!parentTurn) {
|
||||
currentTurn = targetTurn;
|
||||
}
|
||||
});
|
||||
|
||||
turns.forEach((turn) => {
|
||||
const previousTurn = previousTurnsById.get(turn.turnId);
|
||||
const canReuseComputed = (() => {
|
||||
if (!previousTurn) {
|
||||
return false;
|
||||
}
|
||||
if (previousTurn.stream.isStreaming) {
|
||||
return false;
|
||||
}
|
||||
if (!areMessagesEquivalentForReuse(previousTurn.userMessage, turn.userMessage)) {
|
||||
return false;
|
||||
}
|
||||
if (previousTurn.assistantMessages.length !== turn.assistantMessages.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < turn.assistantMessages.length; index += 1) {
|
||||
if (!areMessagesEquivalentForReuse(previousTurn.assistantMessages[index], turn.assistantMessages[index])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})();
|
||||
|
||||
if (canReuseComputed && previousTurn) {
|
||||
turn.summary = previousTurn.summary;
|
||||
turn.summaryText = previousTurn.summaryText;
|
||||
turn.diffStats = previousTurn.diffStats;
|
||||
turn.activityParts = previousTurn.activityParts;
|
||||
turn.activitySegments = previousTurn.activitySegments;
|
||||
turn.hasTools = previousTurn.hasTools;
|
||||
turn.hasReasoning = previousTurn.hasReasoning;
|
||||
turn.stream = previousTurn.stream;
|
||||
turn.startedAt = previousTurn.startedAt;
|
||||
turn.completedAt = previousTurn.completedAt;
|
||||
turn.durationMs = previousTurn.durationMs;
|
||||
return;
|
||||
}
|
||||
|
||||
turn.summary = projectTurnSummary(turn.assistantMessages);
|
||||
turn.summaryText = turn.summary.text ?? getUserSummaryBody(turn.userMessage);
|
||||
turn.diffStats = projectTurnDiffStats(turn.userMessage);
|
||||
|
||||
const activity = projectTurnActivity({
|
||||
turnId: turn.turnId,
|
||||
assistantMessages: turn.assistantMessages,
|
||||
summarySourceMessageId: turn.summary.sourceMessageId,
|
||||
showTextJustificationActivity: effectiveOptions.showTextJustificationActivity,
|
||||
});
|
||||
turn.activityParts = activity.activityParts;
|
||||
turn.activitySegments = activity.activitySegments;
|
||||
turn.hasTools = activity.hasTools;
|
||||
turn.hasReasoning = activity.hasReasoning;
|
||||
|
||||
turn.stream = buildTurnStreamState(turn.userMessage, turn.assistantMessages);
|
||||
turn.startedAt = turn.stream.startedAt;
|
||||
turn.completedAt = turn.stream.completedAt;
|
||||
turn.durationMs = turn.stream.durationMs;
|
||||
});
|
||||
|
||||
const projection = projectTurnIndexes(turns);
|
||||
const ungroupedMessageIds = new Set<string>();
|
||||
messages.forEach((message) => {
|
||||
if (!groupedMessageIds.has(message.info.id)) {
|
||||
ungroupedMessageIds.add(message.info.id);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...projection,
|
||||
ungroupedMessageIds,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ChatMessageEntry, TurnDiffStats, TurnSummaryRecord } from './types';
|
||||
|
||||
interface SummaryDiff {
|
||||
additions?: number | null;
|
||||
deletions?: number | null;
|
||||
}
|
||||
|
||||
interface UserSummaryPayload {
|
||||
body?: string | null;
|
||||
diffs?: SummaryDiff[] | null;
|
||||
}
|
||||
|
||||
const getTextFromPart = (part: unknown): string | undefined => {
|
||||
const text = (part as { text?: unknown }).text;
|
||||
if (typeof text === 'string' && text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
const content = (part as { content?: unknown }).content;
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const projectTurnSummary = (assistantMessages: ChatMessageEntry[]): TurnSummaryRecord => {
|
||||
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const assistantMessage = assistantMessages[messageIndex];
|
||||
if (!assistantMessage) continue;
|
||||
|
||||
const finish = (assistantMessage.info as { finish?: string | null }).finish;
|
||||
if (finish !== 'stop') continue;
|
||||
|
||||
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
||||
const part = assistantMessage.parts[partIndex];
|
||||
if (!part || part.type !== 'text') continue;
|
||||
|
||||
const text = getTextFromPart(part);
|
||||
if (!text) continue;
|
||||
|
||||
return {
|
||||
text,
|
||||
sourceMessageId: assistantMessage.info.id,
|
||||
sourcePartId: part.id ?? `${assistantMessage.info.id}-part-${partIndex}-text`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (let messageIndex = assistantMessages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const assistantMessage = assistantMessages[messageIndex];
|
||||
if (!assistantMessage) continue;
|
||||
|
||||
for (let partIndex = assistantMessage.parts.length - 1; partIndex >= 0; partIndex -= 1) {
|
||||
const part = assistantMessage.parts[partIndex];
|
||||
if (!part || part.type !== 'text') continue;
|
||||
|
||||
const text = getTextFromPart(part);
|
||||
if (!text) continue;
|
||||
|
||||
return {
|
||||
text,
|
||||
sourceMessageId: assistantMessage.info.id,
|
||||
sourcePartId: part.id ?? `${assistantMessage.info.id}-part-${partIndex}-text`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const projectTurnDiffStats = (userMessage: ChatMessageEntry): TurnDiffStats | undefined => {
|
||||
const summary = (userMessage.info as { summary?: UserSummaryPayload | null }).summary;
|
||||
const diffs = summary?.diffs;
|
||||
if (!Array.isArray(diffs) || diffs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let files = 0;
|
||||
|
||||
diffs.forEach((diff) => {
|
||||
if (!diff) return;
|
||||
|
||||
const diffAdditions = typeof diff.additions === 'number' ? diff.additions : 0;
|
||||
const diffDeletions = typeof diff.deletions === 'number' ? diff.deletions : 0;
|
||||
|
||||
if (diffAdditions !== 0 || diffDeletions !== 0) {
|
||||
files += 1;
|
||||
}
|
||||
|
||||
additions += diffAdditions;
|
||||
deletions += diffDeletions;
|
||||
});
|
||||
|
||||
if (files === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
additions,
|
||||
deletions,
|
||||
files,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { projectTurnIndexes } from './projectTurnIndexes';
|
||||
import type { TurnProjectionResult, TurnRecord } from './types';
|
||||
|
||||
const buildTurnSignature = (turn: TurnRecord): string => {
|
||||
const assistantIds = turn.assistantMessageIds.join(',');
|
||||
return [
|
||||
turn.turnId,
|
||||
turn.headerMessageId ?? '',
|
||||
assistantIds,
|
||||
turn.summaryText ?? '',
|
||||
turn.stream.isStreaming ? '1' : '0',
|
||||
turn.stream.isRetrying ? '1' : '0',
|
||||
turn.completedAt ?? '',
|
||||
].join('|');
|
||||
};
|
||||
|
||||
export const stabilizeTurnProjection = (
|
||||
nextProjection: TurnProjectionResult,
|
||||
previousProjection: TurnProjectionResult | null,
|
||||
): TurnProjectionResult => {
|
||||
if (!previousProjection || previousProjection.turns.length === 0 || nextProjection.turns.length === 0) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const previousById = new Map(previousProjection.turns.map((turn) => [turn.turnId, turn]));
|
||||
let reused = false;
|
||||
|
||||
const stabilizedTurns = nextProjection.turns.map((turn, index) => {
|
||||
const isLastTurn = index === nextProjection.turns.length - 1;
|
||||
if (isLastTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
const previousTurn = previousById.get(turn.turnId);
|
||||
if (!previousTurn) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
if (buildTurnSignature(previousTurn) !== buildTurnSignature(turn)) {
|
||||
return turn;
|
||||
}
|
||||
|
||||
reused = true;
|
||||
return previousTurn;
|
||||
});
|
||||
|
||||
if (!reused) {
|
||||
return nextProjection;
|
||||
}
|
||||
|
||||
const projection = projectTurnIndexes(stabilizedTurns);
|
||||
return {
|
||||
...projection,
|
||||
ungroupedMessageIds: nextProjection.ungroupedMessageIds,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TurnStageConfig {
|
||||
init: number;
|
||||
batch: number;
|
||||
}
|
||||
|
||||
export interface UseStageTurnsOptions {
|
||||
sessionKey: string;
|
||||
turnStart: number;
|
||||
totalTurns: number;
|
||||
config?: Partial<TurnStageConfig>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface StageTurnsResult {
|
||||
stagedCount: number;
|
||||
stageStartIndex: number;
|
||||
isStaging: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_STAGE_CONFIG: TurnStageConfig = {
|
||||
init: 1,
|
||||
batch: 3,
|
||||
};
|
||||
|
||||
export const getInitialStageCount = (total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(total, Math.max(1, config.init));
|
||||
};
|
||||
|
||||
export const getNextStageCount = (current: number, total: number, config: TurnStageConfig): number => {
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const batch = Math.max(1, config.batch);
|
||||
return Math.min(total, current + batch);
|
||||
};
|
||||
|
||||
export const getStageStartIndex = (total: number, stagedCount: number): number => {
|
||||
if (stagedCount >= total) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, total - stagedCount);
|
||||
};
|
||||
|
||||
export const useStageTurns = ({
|
||||
sessionKey,
|
||||
turnStart,
|
||||
totalTurns,
|
||||
config,
|
||||
disabled,
|
||||
}: UseStageTurnsOptions): StageTurnsResult => {
|
||||
const effectiveConfig = React.useMemo<TurnStageConfig>(() => {
|
||||
return {
|
||||
init: config?.init ?? DEFAULT_STAGE_CONFIG.init,
|
||||
batch: config?.batch ?? DEFAULT_STAGE_CONFIG.batch,
|
||||
};
|
||||
}, [config?.batch, config?.init]);
|
||||
|
||||
const [state, setState] = React.useState(() => ({
|
||||
activeSession: '',
|
||||
completedSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
|
||||
const stateRef = React.useRef(state);
|
||||
React.useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
const snapshot = stateRef.current;
|
||||
const shouldStage =
|
||||
!disabled
|
||||
&& turnStart > 0
|
||||
&& totalTurns > effectiveConfig.init
|
||||
&& snapshot.completedSession !== sessionKey
|
||||
&& snapshot.activeSession !== sessionKey;
|
||||
|
||||
if (!shouldStage) {
|
||||
setState((previous) => {
|
||||
if (previous.count === totalTurns && previous.activeSession === '') {
|
||||
return previous;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let nextCount = getInitialStageCount(totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
activeSession: sessionKey,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
const step = () => {
|
||||
nextCount = getNextStageCount(nextCount, totalTurns, effectiveConfig);
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
count: nextCount,
|
||||
}));
|
||||
|
||||
if (nextCount >= totalTurns) {
|
||||
setState((previous) => ({
|
||||
...previous,
|
||||
completedSession: sessionKey,
|
||||
activeSession: '',
|
||||
count: totalTurns,
|
||||
}));
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
frameId = window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId !== null && typeof window !== 'undefined') {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [disabled, effectiveConfig, sessionKey, totalTurns, turnStart]);
|
||||
|
||||
const stagedCount = React.useMemo(() => {
|
||||
if (turnStart <= 0 || disabled) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.completedSession === sessionKey) {
|
||||
return totalTurns;
|
||||
}
|
||||
if (state.count <= 0) {
|
||||
return getInitialStageCount(totalTurns, effectiveConfig);
|
||||
}
|
||||
return Math.min(totalTurns, state.count);
|
||||
}, [disabled, effectiveConfig, sessionKey, state.completedSession, state.count, totalTurns, turnStart]);
|
||||
|
||||
return {
|
||||
stagedCount,
|
||||
stageStartIndex: getStageStartIndex(totalTurns, stagedCount),
|
||||
isStaging: !disabled && turnStart > 0 && state.activeSession === sessionKey && state.completedSession !== sessionKey,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export interface ChatMessageEntry {
|
||||
info: Message;
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
export type TurnActivityKind = 'tool' | 'reasoning' | 'justification';
|
||||
|
||||
export interface TurnMessageRecord {
|
||||
messageId: string;
|
||||
role: string;
|
||||
parentMessageId?: string;
|
||||
message: ChatMessageEntry;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface TurnPartRecord {
|
||||
id: string;
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
part: Part;
|
||||
partIndex: number;
|
||||
endedAt?: number;
|
||||
}
|
||||
|
||||
export interface TurnActivityRecord extends TurnPartRecord {
|
||||
kind: TurnActivityKind;
|
||||
}
|
||||
|
||||
export interface TurnDiffStats {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
files: number;
|
||||
}
|
||||
|
||||
export interface TurnActivityGroup {
|
||||
id: string;
|
||||
anchorMessageId: string;
|
||||
afterToolPartId: string | null;
|
||||
parts: TurnActivityRecord[];
|
||||
}
|
||||
|
||||
export interface TurnSummaryRecord {
|
||||
text?: string;
|
||||
sourceMessageId?: string;
|
||||
sourcePartId?: string;
|
||||
}
|
||||
|
||||
export interface TurnStreamState {
|
||||
isStreaming: boolean;
|
||||
isRetrying: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TurnRecord {
|
||||
turnId: string;
|
||||
userMessageId: string;
|
||||
userMessage: ChatMessageEntry;
|
||||
headerMessageId?: string;
|
||||
messages: TurnMessageRecord[];
|
||||
assistantMessageIds: string[];
|
||||
assistantMessages: ChatMessageEntry[];
|
||||
activityParts: TurnActivityRecord[];
|
||||
activitySegments: TurnActivityGroup[];
|
||||
summary: TurnSummaryRecord;
|
||||
summaryText?: string;
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
diffStats?: TurnDiffStats;
|
||||
stream: TurnStreamState;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TurnMessageMeta {
|
||||
turnId: string;
|
||||
messageId: string;
|
||||
userMessageId: string;
|
||||
isUserMessage: boolean;
|
||||
isAssistantMessage: boolean;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
isLastAssistantInTurn: boolean;
|
||||
headerMessageId?: string;
|
||||
}
|
||||
|
||||
export interface TurnIndexes {
|
||||
turnById: Map<string, TurnRecord>;
|
||||
messageToTurnId: Map<string, string>;
|
||||
messageMetaById: Map<string, TurnMessageMeta>;
|
||||
}
|
||||
|
||||
export interface TurnProjectionResult {
|
||||
turns: TurnRecord[];
|
||||
indexes: TurnIndexes;
|
||||
lastTurnId: string | null;
|
||||
lastTurnMessageIds: Set<string>;
|
||||
ungroupedMessageIds: Set<string>;
|
||||
}
|
||||
|
||||
export type Turn = Pick<TurnRecord, 'turnId' | 'userMessage' | 'assistantMessages'>;
|
||||
|
||||
export interface TurnGroupingContext {
|
||||
turnId: string;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
isLastAssistantInTurn: boolean;
|
||||
summaryBody?: string;
|
||||
activityParts?: TurnActivityRecord[];
|
||||
activityGroupSegments?: TurnActivityGroup[];
|
||||
headerMessageId?: string;
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
diffStats?: TurnDiffStats;
|
||||
userMessageCreatedAt?: number;
|
||||
userMessageVariant?: string;
|
||||
isWorking: boolean;
|
||||
isGroupExpanded?: boolean;
|
||||
toggleGroup?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ChatMessageEntry } from './types';
|
||||
import { TURN_WINDOW_DEFAULTS } from './constants';
|
||||
|
||||
const resolveMessageRole = (message: ChatMessageEntry): string => {
|
||||
const role = (message.info as { clientRole?: string | null; role?: string | null }).clientRole ?? message.info.role;
|
||||
return typeof role === 'string' ? role : '';
|
||||
};
|
||||
|
||||
const resolveParentMessageId = (message: ChatMessageEntry): string | undefined => {
|
||||
const parentId = (message.info as { parentID?: unknown }).parentID;
|
||||
if (typeof parentId !== 'string' || parentId.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parentId;
|
||||
};
|
||||
|
||||
export interface TurnWindowModel {
|
||||
turnIds: string[];
|
||||
turnMessageStartIndexes: number[];
|
||||
turnIndexById: Map<string, number>;
|
||||
messageToTurnId: Map<string, string>;
|
||||
messageToTurnIndex: Map<string, number>;
|
||||
turnCount: number;
|
||||
}
|
||||
|
||||
export const buildTurnWindowModel = (messages: ChatMessageEntry[]): TurnWindowModel => {
|
||||
const turnIds: string[] = [];
|
||||
const turnMessageStartIndexes: number[] = [];
|
||||
const turnIndexById = new Map<string, number>();
|
||||
const messageToTurnId = new Map<string, string>();
|
||||
const messageToTurnIndex = new Map<string, number>();
|
||||
const userMessageToTurnIndex = new Map<string, number>();
|
||||
|
||||
let currentTurnIndex = -1;
|
||||
|
||||
messages.forEach((message, index) => {
|
||||
const role = resolveMessageRole(message);
|
||||
const messageId = message.info.id;
|
||||
|
||||
if (role === 'user') {
|
||||
currentTurnIndex = turnIds.length;
|
||||
turnIds.push(messageId);
|
||||
turnMessageStartIndexes.push(index);
|
||||
turnIndexById.set(messageId, currentTurnIndex);
|
||||
userMessageToTurnIndex.set(messageId, currentTurnIndex);
|
||||
messageToTurnId.set(messageId, messageId);
|
||||
messageToTurnIndex.set(messageId, currentTurnIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (role !== 'assistant') {
|
||||
if (currentTurnIndex >= 0) {
|
||||
const turnId = turnIds[currentTurnIndex];
|
||||
if (turnId) {
|
||||
messageToTurnId.set(messageId, turnId);
|
||||
messageToTurnIndex.set(messageId, currentTurnIndex);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const parentId = resolveParentMessageId(message);
|
||||
const parentTurnIndex = parentId ? userMessageToTurnIndex.get(parentId) : undefined;
|
||||
const targetTurnIndex = typeof parentTurnIndex === 'number' ? parentTurnIndex : currentTurnIndex;
|
||||
if (targetTurnIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const turnId = turnIds[targetTurnIndex];
|
||||
if (!turnId) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageToTurnId.set(messageId, turnId);
|
||||
messageToTurnIndex.set(messageId, targetTurnIndex);
|
||||
});
|
||||
|
||||
return {
|
||||
turnIds,
|
||||
turnMessageStartIndexes,
|
||||
turnIndexById,
|
||||
messageToTurnId,
|
||||
messageToTurnIndex,
|
||||
turnCount: turnIds.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInitialTurnStart = (
|
||||
turnCount: number,
|
||||
initialTurns = TURN_WINDOW_DEFAULTS.initialTurns,
|
||||
): number => {
|
||||
if (turnCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return turnCount > initialTurns ? turnCount - initialTurns : 0;
|
||||
};
|
||||
|
||||
export const clampTurnStart = (turnStart: number, turnCount: number): number => {
|
||||
if (turnCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (turnStart <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(turnStart, turnCount - 1);
|
||||
};
|
||||
|
||||
export const getTurnWindowSliceStart = (
|
||||
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
|
||||
turnStart: number,
|
||||
): number => {
|
||||
if (turnStart <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const from = model.turnMessageStartIndexes[turnStart];
|
||||
return typeof from === 'number' ? from : 0;
|
||||
};
|
||||
|
||||
export const windowMessagesByTurn = (
|
||||
messages: ChatMessageEntry[],
|
||||
model: Pick<TurnWindowModel, 'turnMessageStartIndexes'>,
|
||||
turnStart: number,
|
||||
): ChatMessageEntry[] => {
|
||||
const sliceStart = getTurnWindowSliceStart(model, turnStart);
|
||||
if (sliceStart <= 0) {
|
||||
return messages;
|
||||
}
|
||||
return messages.slice(sliceStart);
|
||||
};
|
||||
Reference in New Issue
Block a user