diff --git a/packages/ui/src/components/info-view.tsx b/packages/ui/src/components/info-view.tsx index b2387f5b..accfce72 100644 --- a/packages/ui/src/components/info-view.tsx +++ b/packages/ui/src/components/info-view.tsx @@ -1,20 +1,15 @@ -import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" +import { Component, Show, createMemo } from "solid-js" import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" -import { ChevronDown } from "lucide-solid" import InstanceInfo from "./instance-info" import { useI18n } from "../lib/i18n" +import LogStreamList from "./log-stream-list" interface InfoViewProps { instanceId: string } -const logsScrollState = new Map() - const InfoView: Component = (props) => { const { t } = useI18n() - let scrollRef: HTMLDivElement | undefined - const savedState = logsScrollState.get(props.instanceId) - const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const instance = () => instances().get(props.instanceId) const logs = createMemo(() => getInstanceLogs(props.instanceId)) @@ -22,66 +17,6 @@ const InfoView: Component = (props) => { const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true) const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false) - - onMount(() => { - - if (scrollRef && savedState) { - scrollRef.scrollTop = savedState.scrollTop - } - }) - - onCleanup(() => { - if (scrollRef) { - logsScrollState.set(props.instanceId, { - scrollTop: scrollRef.scrollTop, - autoScroll: autoScroll(), - }) - } - }) - - createEffect(() => { - if (autoScroll() && scrollRef && logs().length > 0) { - scrollRef.scrollTop = scrollRef.scrollHeight - } - }) - - const handleScroll = () => { - if (!scrollRef) return - - const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50 - - setAutoScroll(isAtBottom) - } - - const scrollToBottom = () => { - if (scrollRef) { - scrollRef.scrollTop = scrollRef.scrollHeight - setAutoScroll(true) - } - } - - const formatTime = (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) - } - - const getLevelColor = (level: string) => { - switch (level) { - case "error": - return "log-level-error" - case "warn": - return "log-level-warn" - case "debug": - return "log-level-debug" - default: - return "log-level-default" - } - } return (
@@ -108,56 +43,22 @@ const InfoView: Component = (props) => {
- -
- -

{t("infoView.logs.paused.title")}

-

{t("infoView.logs.paused.description")}

- -
- } - > - 0} - fallback={
{t("infoView.logs.empty.waiting")}
} - > - - {(entry) => ( -
- - {formatTime(entry.timestamp)} - - {entry.message} -
- )} -
-
- - - - - - + + ) } - export default InfoView diff --git a/packages/ui/src/components/log-stream-list.tsx b/packages/ui/src/components/log-stream-list.tsx new file mode 100644 index 00000000..0d781ad4 --- /dev/null +++ b/packages/ui/src/components/log-stream-list.tsx @@ -0,0 +1,156 @@ +import { ChevronDown } from "lucide-solid" +import { Index, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import VirtualItem from "./virtual-item" +import type { LogEntry } from "../types/instance" + +const LOG_AUTOSCROLL_TOLERANCE_PX = 50 +const LOG_VIRTUALIZATION_THRESHOLD = 120 +const LOG_VISIBLE_TAIL_COUNT = 80 +const LOG_OVERSCAN_PX = 800 +const LOG_PLACEHOLDER_HEIGHT_PX = 28 + +const logsScrollState = new Map() + +interface LogStreamListProps { + scrollStateKey: string + logs: Accessor + streamingEnabled: Accessor + onEnableLogs: () => void + emptyLabel: string + pausedTitle: string + pausedDescription: string + showLogsLabel: string + scrollToBottomLabel: string +} + +function formatTime(timestamp: number) { + const date = new Date(timestamp) + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +function getLevelColor(level: string) { + switch (level) { + case "error": + return "log-level-error" + case "warn": + return "log-level-warn" + case "debug": + return "log-level-debug" + default: + return "log-level-default" + } +} + +export default function LogStreamList(props: LogStreamListProps) { + let scrollRef: HTMLDivElement | undefined + const savedState = logsScrollState.get(props.scrollStateKey) + const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) + + const logCount = createMemo(() => props.logs().length) + const virtualizationEnabled = createMemo(() => logCount() >= LOG_VIRTUALIZATION_THRESHOLD) + + onMount(() => { + if (scrollRef && savedState) { + scrollRef.scrollTop = savedState.scrollTop + } + }) + + onCleanup(() => { + if (!scrollRef) { + return + } + + logsScrollState.set(props.scrollStateKey, { + scrollTop: scrollRef.scrollTop, + autoScroll: autoScroll(), + }) + }) + + createEffect(() => { + if (!autoScroll() || !scrollRef || logCount() === 0) { + return + } + + requestAnimationFrame(() => { + if (!scrollRef) { + return + } + scrollRef.scrollTop = scrollRef.scrollHeight + }) + }) + + const handleScroll = () => { + if (!scrollRef) { + return + } + + const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + LOG_AUTOSCROLL_TOLERANCE_PX + setAutoScroll(isAtBottom) + } + + const scrollToBottom = () => { + if (!scrollRef) { + return + } + + scrollRef.scrollTop = scrollRef.scrollHeight + setAutoScroll(true) + } + + return ( + <> +
+ +

{props.pausedTitle}

+

{props.pausedDescription}

+ +
+ } + > + 0} fallback={
{props.emptyLabel}
}> + + {(entry, index) => { + const key = () => `${entry().timestamp}:${entry().level}:${index}` + const forceVisible = () => index >= Math.max(0, logCount() - LOG_VISIBLE_TAIL_COUNT) + + return ( + scrollRef} + threshold={LOG_OVERSCAN_PX} + minPlaceholderHeight={LOG_PLACEHOLDER_HEIGHT_PX} + placeholderClass="log-entry-placeholder" + virtualizationEnabled={virtualizationEnabled} + forceVisible={forceVisible} + > +
+ {formatTime(entry().timestamp)} + {entry().message} +
+
+ ) + }} +
+
+ + + + + + + + ) +} diff --git a/packages/ui/src/components/logs-view.tsx b/packages/ui/src/components/logs-view.tsx index 7d6e7860..8661cf78 100644 --- a/packages/ui/src/components/logs-view.tsx +++ b/packages/ui/src/components/logs-view.tsx @@ -1,19 +1,14 @@ -import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" -import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" -import { ChevronDown } from "lucide-solid" +import { Component, For, Show, createMemo } from "solid-js" +import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { useI18n } from "../lib/i18n" +import LogStreamList from "./log-stream-list" interface LogsViewProps { instanceId: string } -const logsScrollState = new Map() - const LogsView: Component = (props) => { const { t } = useI18n() - let scrollRef: HTMLDivElement | undefined - const savedState = logsScrollState.get(props.instanceId) - const [autoScroll, setAutoScroll] = createSignal(savedState?.autoScroll ?? false) const instance = () => instances().get(props.instanceId) const logs = createMemo(() => getInstanceLogs(props.instanceId)) @@ -21,66 +16,6 @@ const LogsView: Component = (props) => { const handleEnableLogs = () => setInstanceLogStreaming(props.instanceId, true) const handleDisableLogs = () => setInstanceLogStreaming(props.instanceId, false) - - onMount(() => { - - if (scrollRef && savedState) { - scrollRef.scrollTop = savedState.scrollTop - } - }) - - onCleanup(() => { - if (scrollRef) { - logsScrollState.set(props.instanceId, { - scrollTop: scrollRef.scrollTop, - autoScroll: autoScroll(), - }) - } - }) - - createEffect(() => { - if (autoScroll() && scrollRef && logs().length > 0) { - scrollRef.scrollTop = scrollRef.scrollHeight - } - }) - - const handleScroll = () => { - if (!scrollRef) return - - const isAtBottom = scrollRef.scrollHeight - scrollRef.scrollTop <= scrollRef.clientHeight + 50 - - setAutoScroll(isAtBottom) - } - - const scrollToBottom = () => { - if (scrollRef) { - scrollRef.scrollTop = scrollRef.scrollHeight - setAutoScroll(true) - } - } - - const formatTime = (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) - } - - const getLevelColor = (level: string) => { - switch (level) { - case "error": - return "log-level-error" - case "warn": - return "log-level-warn" - case "debug": - return "log-level-debug" - default: - return "log-level-default" - } - } return (
@@ -102,13 +37,13 @@ const LogsView: Component = (props) => {
- 0}> + 0}>
- {t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables!).length })} + {t("logsView.envVars.title", { count: Object.keys(instance()?.environmentVariables ?? {}).length })}
- + {([key, value]) => (
{key} @@ -123,50 +58,18 @@ const LogsView: Component = (props) => {
-
- -

{t("logsView.paused.title")}

-

{t("logsView.paused.description")}

- -
- } - > - 0} - fallback={
{t("logsView.empty.waiting")}
} - > - - {(entry) => ( -
- {formatTime(entry.timestamp)} - {entry.message} -
- )} -
-
- -
- - - - +
- ) } diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 5da2432f..941618e0 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -15,6 +15,7 @@ import { showToastNotification } from "../lib/notifications" import { showAlertDialog } from "../stores/alerts" import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" +import type { MessageRecord } from "../stores/message-v2/types" import type { DeleteHoverState } from "../types/delete-hover" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { getPartCharCount } from "../lib/token-utils" @@ -22,6 +23,8 @@ import { getPartCharCount } from "../lib/token-utils" const SCROLL_SENTINEL_MARGIN_PX = 48 const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const QUOTE_SELECTION_MAX_LENGTH = 2000 +const FOLLOW_MODE_VIRTUALIZATION_THRESHOLD = 40 +const FOLLOW_MODE_VISIBLE_TAIL_COUNT = 24 const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href export interface MessageSectionProps { @@ -41,7 +44,7 @@ export interface MessageSectionProps { export default function MessageSection(props: MessageSectionProps) { const { preferences } = useConfig() - const { t } = useI18n() + const { t, locale } = useI18n() const showUsagePreference = () => preferences().showUsageMetrics ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const store = createMemo(() => messageStoreBus.getOrCreate(props.instanceId)) @@ -332,6 +335,7 @@ export default function MessageSection(props: MessageSectionProps) { const seenTimelineMessageIds = new Set() const seenTimelineSegmentKeys = new Set() const timelinePartCountsByMessageId = new Map() + const timelineSegmentsCache = new Map() let pendingTimelineMessagePartUpdates = new Set() let pendingTimelinePartUpdateFrame: number | null = null @@ -339,6 +343,22 @@ export default function MessageSection(props: MessageSectionProps) { return `${segment.messageId}:${segment.id}:${segment.type}` } + function getTimelineSegmentsForMessage(record: MessageRecord) { + const localeKey = locale() + const cached = timelineSegmentsCache.get(record.id) + if (cached && cached.revision === record.revision && cached.locale === localeKey) { + return cached.segments + } + + const built = buildTimelineSegments(props.instanceId, record, t) + timelineSegmentsCache.set(record.id, { + revision: record.revision, + locale: localeKey, + segments: built, + }) + return built + } + function seedTimeline() { seenTimelineMessageIds.clear() seenTimelineSegmentKeys.clear() @@ -351,7 +371,7 @@ export default function MessageSection(props: MessageSectionProps) { if (!record) return seenTimelineMessageIds.add(messageId) timelinePartCountsByMessageId.set(messageId, record.partIds.length) - const built = buildTimelineSegments(props.instanceId, record, t) + const built = getTimelineSegmentsForMessage(record) built.forEach((segment) => { const key = makeTimelineKey(segment) if (seenTimelineSegmentKeys.has(key)) return @@ -366,7 +386,7 @@ export default function MessageSection(props: MessageSectionProps) { const record = untrack(() => store().getMessage(messageId)) if (!record) return timelinePartCountsByMessageId.set(messageId, record.partIds.length) - const built = buildTimelineSegments(props.instanceId, record, t) + const built = getTimelineSegmentsForMessage(record) if (built.length === 0) return const newSegments: TimelineSegment[] = [] built.forEach((segment) => { @@ -731,9 +751,16 @@ export default function MessageSection(props: MessageSectionProps) { let previousTimelineIds: string[] = [] createEffect(() => { + const active = isActive() const loading = Boolean(props.loading) const ids = messageIds() + if (!active) { + clearPendingTimelinePartUpdateFrame() + pendingTimelineMessagePartUpdates.clear() + return + } + if (loading) { handleClearTimelineSelection() previousTimelineIds = [] @@ -795,6 +822,39 @@ export default function MessageSection(props: MessageSectionProps) { timelinePartCountsByMessageId.set(newId, existingPartCount) } + setSelectedTimelineIds((prev) => { + if (prev.size === 0) return prev + let changed = false + const next = new Set() + for (const id of prev) { + if (id.includes(oldId)) { + next.add(id.replace(oldId, newId)) + changed = true + } else { + next.add(id) + } + } + return changed ? next : prev + }) + + setLastSelectionAnchorId((prev) => { + if (!prev || !prev.includes(oldId)) return prev + return prev.replace(oldId, newId) + }) + + setActiveSegmentId((prev) => { + if (!prev || !prev.includes(oldId)) return prev + return prev.replace(oldId, newId) + }) + + setSelectedForDeletion((prev) => { + if (!prev.has(oldId)) return prev + const next = new Set(prev) + next.delete(oldId) + next.add(newId) + return next + }) + previousTimelineIds = ids.slice() return } @@ -844,7 +904,7 @@ export default function MessageSection(props: MessageSectionProps) { next = next.filter((segment) => segment.messageId !== changedId) const record = resolvedStore.getMessage(changedId) - const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : [] + const rebuilt = record ? getTimelineSegmentsForMessage(record) : [] // Insert rebuilt segments in the correct place based on session message order. if (rebuilt.length > 0) { @@ -884,6 +944,12 @@ export default function MessageSection(props: MessageSectionProps) { // Part deletion does not remove message ids from the session, so we must // explicitly replace segments for messages whose part count changed. createEffect(() => { + if (!isActive()) { + clearPendingTimelinePartUpdateFrame() + pendingTimelineMessagePartUpdates.clear() + return + } + if (props.loading) return const ids = messageIds() const resolvedStore = store() @@ -926,6 +992,10 @@ export default function MessageSection(props: MessageSectionProps) { createEffect(() => { + if (!isActive()) { + return + } + if (typeof document === "undefined") return const handleSelectionChange = () => updateQuoteSelectionFromSelection() const handlePointerDown = (event: PointerEvent) => { @@ -944,12 +1014,21 @@ export default function MessageSection(props: MessageSectionProps) { }) createEffect(() => { + if (!isActive()) { + clearQuoteSelection() + return + } + if (props.loading) { clearQuoteSelection() } }) createEffect(() => { + if (!isActive()) { + return + } + if (typeof document === "undefined") return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && (selectedTimelineIds().size > 0 || selectedForDeletion().size > 0)) { @@ -995,6 +1074,8 @@ export default function MessageSection(props: MessageSectionProps) { getKeyFromAnchorId={getMessageIdFromAnchorId} overscanPx={800} scrollSentinelMarginPx={SCROLL_SENTINEL_MARGIN_PX} + virtualizeWhileAutoScroll={() => messageIds().length >= FOLLOW_MODE_VIRTUALIZATION_THRESHOLD} + forceVisible={(_messageId, index) => index >= Math.max(0, messageIds().length - FOLLOW_MODE_VISIBLE_TAIL_COUNT)} suspendMeasurements={() => !isActive()} loading={() => Boolean(props.loading)} isActive={isActive} diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 567f6fd9..02fc6b53 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -31,6 +31,7 @@ export interface VirtualFollowListProps { items: Accessor getKey: (item: T, index: number) => string renderItem: (item: T, index: number) => JSX.Element + forceVisible?: (item: T, index: number) => boolean /** * Optional stable DOM id for the item wrapper. @@ -47,6 +48,7 @@ export interface VirtualFollowListProps { overscanPx?: number scrollSentinelMarginPx?: number virtualizationEnabled?: Accessor + virtualizeWhileAutoScroll?: Accessor suspendMeasurements?: Accessor loading?: Accessor isActive?: Accessor @@ -137,6 +139,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const isLoading = () => Boolean(props.loading?.()) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) + const virtualizeWhileAutoScroll = () => (props.virtualizeWhileAutoScroll ? props.virtualizeWhileAutoScroll() : false) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) @@ -922,7 +925,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const anchorId = () => getAnchorId(key()) const overscanPx = props.overscanPx ?? 800 const suspendMeasurements = () => measurementsSuspended() || !isActive() - const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll() + const itemVirtualizationEnabled = () => virtualizationEnabled() && (virtualizeWhileAutoScroll() || !autoScroll()) return ( (props: VirtualFollowListProps) { threshold={overscanPx} placeholderClass="message-stream-placeholder" virtualizationEnabled={itemVirtualizationEnabled} + forceVisible={() => Boolean(props.forceVisible?.(item(), index))} suspendMeasurements={suspendMeasurements} onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => { const delta = nextHeight - previousHeight diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 2d4bb6ec..85278034 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -85,10 +85,72 @@ interface DisconnectedInstanceInfo { const [disconnectedInstance, setDisconnectedInstance] = createSignal(null) const MAX_LOG_ENTRIES = 1000 +const pendingLogEntries = new Map() +let pendingLogFlushFrame: number | null = null const pendingDisposeRequests = new Map>() const pendingRehydrations = new Map>() +function flushPendingLogs() { + pendingLogFlushFrame = null + if (pendingLogEntries.size === 0) { + return + } + + setInstanceLogs((prev) => { + let next: Map | null = null + + for (const [instanceId, queuedEntries] of pendingLogEntries.entries()) { + if (queuedEntries.length === 0 || !isInstanceLogStreaming(instanceId)) { + continue + } + + const boundedQueuedEntries = queuedEntries.length > MAX_LOG_ENTRIES + ? queuedEntries.slice(-MAX_LOG_ENTRIES) + : queuedEntries + + const target = next ?? prev + const existing = target.get(instanceId) ?? [] + const updated = existing.length === 0 + ? boundedQueuedEntries + : [...existing, ...boundedQueuedEntries].slice(-MAX_LOG_ENTRIES) + + if (updated === existing) { + continue + } + + if (!next) { + next = new Map(prev) + } + next.set(instanceId, updated) + } + + pendingLogEntries.clear() + return next ?? prev + }) +} + +function schedulePendingLogFlush() { + if (pendingLogFlushFrame !== null) { + return + } + + if (typeof requestAnimationFrame === "function") { + pendingLogFlushFrame = requestAnimationFrame(() => flushPendingLogs()) + return + } + + queueMicrotask(flushPendingLogs) +} + +function discardPendingLogs(id: string) { + pendingLogEntries.delete(id) + if (pendingLogEntries.size === 0 && pendingLogFlushFrame !== null) { + cancelAnimationFrame(pendingLogFlushFrame) + pendingLogFlushFrame = null + } +} + function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance { const existing = instances().get(descriptor.id) return { @@ -419,6 +481,7 @@ function ensureLogStreamingState(id: string) { } function removeLogContainer(id: string) { + discardPendingLogs(id) setInstanceLogs((prev) => { if (!prev.has(id)) { return prev @@ -453,6 +516,7 @@ function setInstanceLogStreaming(instanceId: string, enabled: boolean) { return next }) if (!enabled) { + discardPendingLogs(instanceId) clearLogs(instanceId) } } @@ -579,16 +643,21 @@ function addLog(id: string, entry: LogEntry) { return } - setInstanceLogs((prev) => { - const next = new Map(prev) - const existing = next.get(id) ?? [] - const updated = existing.length >= MAX_LOG_ENTRIES ? [...existing.slice(1), entry] : [...existing, entry] - next.set(id, updated) - return next - }) + const queued = pendingLogEntries.get(id) + if (queued) { + queued.push(entry) + if (queued.length > MAX_LOG_ENTRIES) { + queued.splice(0, queued.length - MAX_LOG_ENTRIES) + } + } else { + pendingLogEntries.set(id, [entry]) + } + + schedulePendingLogFlush() } function clearLogs(id: string) { + discardPendingLogs(id) setInstanceLogs((prev) => { if (!prev.has(id)) { return prev diff --git a/packages/ui/src/styles/messaging/log-view.css b/packages/ui/src/styles/messaging/log-view.css index 6f362364..7ade9515 100644 --- a/packages/ui/src/styles/messaging/log-view.css +++ b/packages/ui/src/styles/messaging/log-view.css @@ -20,6 +20,11 @@ @apply flex gap-3 py-0.5 px-2 -mx-2 rounded transition-colors; } +.log-entry-placeholder { + display: block; + width: 100%; +} + .log-entry:hover { background-color: var(--surface-hover); }