<script lang="ts">
import { onDestroy } from "svelte";
import type { Virtualizer } from "@tanstack/virtual-core";
import { messages } from "../../stores/messages.svelte.js";
import { ui } from "../../stores/ui.svelte.js";
import { sessions } from "../../stores/sessions.svelte.js";
import { createVirtualizer } from "../../virtual/createVirtualizer.svelte.js";
import MessageContent from "./MessageContent.svelte";
import ToolCallGroup from "./ToolCallGroup.svelte";
import type { Message } from "../../api/types.js";
import {
buildDisplayItems,
type DisplayItem,
} from "../../utils/display-items.js";
import { filterDisplayItemsByTranscriptMode } from "../../utils/transcript-mode.js";
import {
hasVisibleSegments,
} from "../../utils/content-parser.js";
import { isSystemMessage } from "../../utils/messages.js";
import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js";
import SessionFindBar from "./SessionFindBar.svelte";
import AppIcon from "../layout/AppIcon.svelte";
let containerRef: HTMLDivElement | undefined = $state(undefined);
let scrollRaf: number | null = $state(null);
let lastScrollRequest = 0;
let baseMessages: Message[] = $derived.by(() =>
messages.messages.filter((m) => !isSystemMessage(m)),
);
let baseDisplayItemsAsc = $derived(
buildDisplayItems(baseMessages),
);
let filteredDisplayItemsAsc = $derived(
buildDisplayItems(baseMessages, {
skipToolGrouping: !ui.isBlockVisible("tool"),
}),
);
function isItemVisible(item: DisplayItem): boolean {
if (item.kind === "tool-group") {
return true;
}
return hasVisibleSegments(item.message, (type) =>
ui.isBlockVisible(type),
);
}
let normalDisplayItemsAsc = $derived.by(() => {
if (!ui.hasBlockFilters) return baseDisplayItemsAsc;
return filteredDisplayItemsAsc.filter(isItemVisible);
});
let displayItemsAsc = $derived.by(() => {
if (ui.transcriptMode === "normal") {
return normalDisplayItemsAsc;
}
if (!ui.hasBlockFilters) {
return filterDisplayItemsByTranscriptMode(
baseDisplayItemsAsc,
"focused",
);
}
return filterDisplayItemsByTranscriptMode(
filteredDisplayItemsAsc,
"focused",
{
isMessageVisible: (message) =>
hasVisibleSegments(message, (type) =>
ui.isBlockVisible(type),
),
},
).filter(isItemVisible);
});
function itemAt(index: number) {
if (ui.sortNewestFirst) {
const mapped = displayItemsAsc.length - 1 - index;
return displayItemsAsc[mapped];
}
return displayItemsAsc[index];
}
const virtualizer = createVirtualizer(() => {
const items = displayItemsAsc;
const newestFirst = ui.sortNewestFirst;
const count = items.length;
const el = containerRef ?? null;
const sid = sessions.activeSessionId ?? "";
return {
count,
getScrollElement: () => el,
estimateSize: () => 120,
overscan: 5,
useAnimationFrameWithResizeObserver: true,
measureCacheKey: sid,
getItemKey: (index: number) => {
const item = newestFirst ? items[count - 1 - index] : items[index];
if (!item) return `${sid}-${index}`;
if (item.kind === "tool-group") {
return `${sid}-tg-${item.ordinals[0]}`;
}
return `${sid}-m-${item.message.ordinal}`;
},
};
});
function measureElement(
node: HTMLElement,
virt: Virtualizer<HTMLElement, HTMLElement> | undefined,
) {
virt?.measureElement(node);
return {
update(
nextVirt:
| Virtualizer<HTMLElement, HTMLElement>
| undefined,
) {
nextVirt?.measureElement(node);
},
destroy() {
},
};
}
function handleScroll() {
if (!containerRef) return;
if (scrollRaf !== null) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
if (!containerRef) return;
const items = virtualizer.instance?.getVirtualItems() ?? [];
if (items.length > 0 && messages.hasOlder) {
const firstVisible = items[0]!.index;
const lastVisible = items[items.length - 1]!.index;
const threshold = 30;
if (
(ui.sortNewestFirst &&
lastVisible >= displayItemsAsc.length - threshold) ||
(!ui.sortNewestFirst && firstVisible <= threshold)
) {
messages.loadOlder();
}
}
});
}
onDestroy(() => {
if (scrollRaf !== null) {
cancelAnimationFrame(scrollRaf);
scrollRaf = null;
}
});
function scrollToDisplayIndex(
index: number,
waitFrames: number = 0,
scrollRetries: number = 0,
reqId: number = lastScrollRequest,
) {
if (reqId !== lastScrollRequest) return;
const v = virtualizer.instance;
if (!v) return;
const desiredCount = displayItemsAsc.length;
const virtualCount = v.options.count;
if (
waitFrames < 5 &&
(virtualCount !== desiredCount || index >= virtualCount)
) {
requestAnimationFrame(() => {
scrollToDisplayIndex(
index, waitFrames + 1, 0, reqId,
);
});
return;
}
const virtualItems = v.getVirtualItems();
const isRendered = virtualItems.some(
(vi) => vi.index === index,
);
if (isRendered) {
const offsetAndAlign =
v.getOffsetForIndex(index, "start");
if (offsetAndAlign) {
const [offset] = offsetAndAlign;
v.scrollToOffset(
Math.round(offset),
{ align: "start" },
);
}
return;
}
v.scrollToIndex(index, { align: "start" });
if (scrollRetries < 15) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scrollToDisplayIndex(
index, waitFrames, scrollRetries + 1, reqId,
);
});
});
}
}
function raf(): Promise<void> {
return new Promise((r) => requestAnimationFrame(() => r()));
}
async function scrollToOrdinalInternal(ordinal: number) {
const reqId = ++lastScrollRequest;
const idxAsc = displayItemsAsc.findIndex((item) =>
item.ordinals.includes(ordinal),
);
if (idxAsc >= 0) {
const idx = ui.sortNewestFirst
? displayItemsAsc.length - 1 - idxAsc
: idxAsc;
scrollToDisplayIndex(idx, 0, 0, reqId);
return;
}
await messages.ensureOrdinalLoaded(ordinal);
if (reqId !== lastScrollRequest) return;
await raf();
await raf();
if (reqId !== lastScrollRequest) return;
const loadedIdxAsc = displayItemsAsc.findIndex(
(item) => item.ordinals.includes(ordinal),
);
if (loadedIdxAsc < 0) return;
const loadedIdx = ui.sortNewestFirst
? displayItemsAsc.length - 1 - loadedIdxAsc
: loadedIdxAsc;
scrollToDisplayIndex(loadedIdx, 0, 0, reqId);
}
export function scrollToOrdinal(ordinal: number) {
void scrollToOrdinalInternal(ordinal);
}
export function getDisplayItems(): DisplayItem[] {
return displayItemsAsc;
}
export function getNormalDisplayItems(): DisplayItem[] {
return normalDisplayItemsAsc;
}
let highlightQuery = $derived(
inSessionSearch.isOpen && inSessionSearch.query.trim().length > 0
? inSessionSearch.query
: "",
);
</script>
{#if !sessions.activeSessionId}
<div class="empty-state">
<div class="empty-icon">
<AppIcon name="messages-square" size={36} />
</div>
<p class="empty-text">Select a session to view messages</p>
</div>
{:else if messages.loading && messages.messages.length === 0}
<div class="empty-state">
<p class="empty-text">Loading messages...</p>
</div>
{:else}
<SessionFindBar />
<div
class="message-list-scroll layout-{ui.messageLayout}"
bind:this={containerRef}
data-session-id={sessions.activeSessionId}
data-messages-session-id={messages.sessionId}
data-loaded={!messages.loading}
onscroll={handleScroll}
>
<div
style="height: {virtualizer.instance?.getTotalSize() ?? 0}px; width: 100%; position: relative;"
>
{#each virtualizer.instance?.getVirtualItems() ?? [] as row (row.key)}
{@const item = itemAt(row.index)}
{#if item}
<div
class="virtual-row"
class:selected={ui.selectedOrdinal !== null &&
item.ordinals.includes(ui.selectedOrdinal)}
data-index={row.index}
style="position: absolute; top: 0; left: 0; width: 100%; transform: translateY({row.start}px);"
use:measureElement={virtualizer.instance}
onclick={() => {
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
ui.selectOrdinal(item.ordinals[0]!);
}}
>
{#if item.kind === "tool-group"}
<ToolCallGroup
messages={item.messages}
timestamp={item.timestamp}
highlightQuery={highlightQuery}
isCurrentHighlight={item.ordinals.includes(inSessionSearch.currentOrdinal ?? -1)}
/>
{:else}
<MessageContent
message={item.message}
highlightQuery={highlightQuery}
isCurrentHighlight={inSessionSearch.currentOrdinal === item.message.ordinal}
/>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
<style>
.message-list-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: var(--wb-spacing-8) 0;
overflow-anchor: none;
}
.virtual-row {
padding: var(--wb-spacing-5) var(--wb-spacing-12);
overflow-anchor: none;
}
.virtual-row.selected > :global(*) {
outline: 2px solid var(--vscode-focusBorder);
outline-offset: -2px;
border-radius: var(--wsb-radius-md, var(--wb-corner-radius-6));
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--vscode-disabledForeground);
gap: var(--wb-spacing-12);
}
.empty-icon {
opacity: 0.25;
}
.empty-text {
font-size: var(--wb-font-size-14);
font-weight: 500;
}
.layout-compact {
padding: var(--wb-spacing-4) 0;
}
.layout-compact .virtual-row {
padding: var(--wb-spacing-2) var(--wb-spacing-12);
}
.layout-compact :global(.message) {
padding: var(--wb-spacing-6) var(--wb-spacing-12);
border-left-width: 2px;
border-radius: 0;
}
.layout-compact :global(.message-header) {
margin-bottom: var(--wb-spacing-4);
gap: var(--wb-spacing-6);
}
.layout-compact :global(.role-icon) {
width: var(--wb-control-size-16);
height: var(--wb-control-size-16);
font-size: var(--wb-font-size-9);
}
.layout-compact :global(.role-label) {
font-size: var(--wb-font-size-10);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
}
.layout-compact :global(.timestamp),
.layout-compact :global(.group-timestamp) {
font-size: var(--wb-font-size-10);
}
.layout-compact :global(.text-content) {
font-size: var(--wb-font-size-13);
line-height: 1.55;
}
.layout-compact :global(.message-body) {
gap: var(--wb-spacing-4);
}
.layout-stream {
padding: 0;
}
.layout-stream .virtual-row {
padding: 0;
}
.layout-stream :global(.message) {
border-left: none;
border-radius: 0;
padding: var(--wb-spacing-16) var(--wb-spacing-24);
}
.layout-stream :global(.message.is-user) {
background: color-mix(
in srgb,
var(--vscode-focusBorder) 5%,
transparent
) !important;
}
.layout-stream :global(.message:not(.is-user)) {
background: transparent !important;
}
.layout-stream :global(.message-header) {
display: none;
}
.layout-stream :global(.text-content) {
font-size: var(--wb-font-size-14);
line-height: 1.75;
}
</style>