diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 004d85d1e..dd8067842 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -609,6 +609,7 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, instanceSessions: allInstanceSessions, activeSessionId: activeSessionIdForInstance, + visible: () => Boolean(props.isActiveInstance), }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..d46d793fa 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,10 +1,6 @@ -import { createEffect, createSignal, type Accessor } from "solid-js" +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" import { messageStoreBus } from "../../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../../message-block" -import { getLogger } from "../../../lib/logger" -import { invalidateSessionMessageLoad } from "../../../stores/session-state" - -const log = getLogger("session") +import { evictResidentSessionMessages, setVisibleSessionMemory } from "../../../stores/session-memory" const SESSION_CACHE_LIMIT = 5 @@ -12,6 +8,7 @@ type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + visible: Accessor } type SessionCacheState = { @@ -23,13 +20,8 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState const [pendingEvictions, setPendingEvictions] = createSignal([]) const evictSession = (sessionId: string) => { - if (!sessionId) return - const instanceId = options.instanceId() - log.info("Evicting cached session", { instanceId, sessionId }) - const store = messageStoreBus.getInstance(instanceId) - invalidateSessionMessageLoad(instanceId, sessionId) - store?.clearSession(sessionId, { preserveScroll: true, notify: false }) - clearSessionRenderCache(instanceId, sessionId) + if (!sessionId) return false + return evictResidentSessionMessages(options.instanceId(), sessionId) } const scheduleEvictions = (ids: string[]) => { @@ -50,13 +42,15 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState createEffect(() => { const pending = pendingEvictions() if (!pending.length) return + const store = messageStoreBus.getInstance(options.instanceId()) const cached = new Set(cachedSessionIds()) const remaining: string[] = [] pending.forEach((id) => { + store?.getSessionRevision(id) if (cached.has(id)) { remaining.push(id) - } else { - evictSession(id) + } else if (!evictSession(id)) { + remaining.push(id) } }) if (remaining.length !== pending.length) { @@ -64,6 +58,16 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState } }) + createEffect(() => { + const instanceId = options.instanceId() + const sessionId = options.activeSessionId() + if (!sessionId || sessionId === "info") return + const isVisible = options.visible() + if (!isVisible) return + setVisibleSessionMemory(instanceId, sessionId, true) + onCleanup(() => setVisibleSessionMemory(instanceId, sessionId, false)) + }) + createEffect(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index cc3691ac5..e38596cfe 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -188,6 +188,7 @@ function clearInstanceCaches(instanceId: string) { } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) +messageStoreBus.onSessionCleared(clearSessionRenderCache) function removeSearchMarks(root: HTMLElement) { const marks = Array.from(root.querySelectorAll("mark.session-search-match")) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index f80302338..693e2c423 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -11,6 +11,7 @@ import { isScrollRestoreGenerationCurrent, isSnapshotAutoFollowing } from "./vir import { useConfig } from "../stores/preferences" import { getSessionInfo } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" +import { isRestoringCachedSessionMessages } from "../stores/session-message-cache" import { useI18n } from "../lib/i18n" import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" @@ -884,7 +885,6 @@ export default function MessageSection(props: MessageSectionProps) { const api = listApi() if (!element || !api) return if (!isActive()) return - if (props.loading) return if (visibleMessageIds().length === 0) return if (didRestoreScroll()) return @@ -1064,7 +1064,7 @@ export default function MessageSection(props: MessageSectionProps) { // to prevent O(n) per-element reactive subscriptions. The effect // only needs to re-run when `messageIds` (memo) changes. untrack(() => { - if (loading) { + if (loading && ids.length === 0) { handleClearTimelineSelection() previousTimelineIds = [] setTimelineSegments([]) @@ -1131,6 +1131,14 @@ export default function MessageSection(props: MessageSectionProps) { } } + const prefixAdded = previousTimelineIds.length > 0 && ids.length > previousTimelineIds.length && + previousTimelineIds.every((id, index) => ids[ids.length - previousTimelineIds.length + index] === id) + if (prefixAdded) { + seedTimeline() + previousTimelineIds = [...ids] + return + } + const newIds: string[] = [] ids.forEach((id) => { if (!seenTimelineMessageIds.has(id)) { @@ -1430,6 +1438,7 @@ export default function MessageSection(props: MessageSectionProps) { getAnchorId={getMessageAnchorId} overscanPx={800} streamingActive={streamingActive} + shift={() => isRestoringCachedSessionMessages(props.instanceId, props.sessionId)} isActive={isActive} scrollToBottomOnActivate={() => false} initialScrollToBottom={() => false} @@ -1585,7 +1594,7 @@ export default function MessageSection(props: MessageSectionProps) { - +

{t("messageSection.loading.messages")}

diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index af11017a2..0df4a874f 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -23,6 +23,8 @@ import { SessionPreviewView } from "../session-preview-view" import { isSnapshotAutoFollowing } from "../virtual-follow-behavior" import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent" import { focusConversationStream } from "../focus-conversation" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" +import { invalidateSessionMessageLoad } from "../../stores/session-state" const log = getLogger("session") @@ -431,6 +433,11 @@ export const SessionView: Component = (props) => { }), "session.revert", ) + if (instances().get(props.instanceId) !== instance) return + if (messageStore().getSessionRevert(props.sessionId)?.messageID !== messageId) { + invalidateSessionMessageLoad(props.instanceId, props.sessionId) + invalidateSessionMessageCache(props.instanceId, props.sessionId) + } const restoredText = getUserMessageText(messageId) if (restoredText) { diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 0446ca23b..46bd9f607 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -11,6 +11,7 @@ import type { PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" +import { exceedsRetainedByteLimit } from "../lib/session-memory-budget" import { resolveToolRenderer } from "./tool-call/renderers" import { resolveToolExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" import { QuestionToolBlock } from "./tool-call/question-block" @@ -39,7 +40,9 @@ import { isToolStateError, isToolStateRunning, getDefaultToolAction, + limitToolOutputForRender, readToolStatePayload, + TOOL_OUTPUT_RENDER_CHARACTER_LIMIT, } from "./tool-call/utils" import { getLogger } from "../lib/logger" import { useSpeech } from "../lib/hooks/use-speech" @@ -73,6 +76,7 @@ interface ToolCallProps { partVersion?: number instanceId: string sessionId: string + visibilitySessionId?: string onContentRendered?: () => void /** * When true, tool call starts collapsed regardless of user preferences. @@ -111,6 +115,7 @@ function ToolCallDetails(props: { toolCallIdentifier: () => string instanceId: string sessionId: string + visibilitySessionId: string messageId?: string messageVersion?: number partVersion?: number @@ -354,9 +359,12 @@ function ToolCallDetails(props: { const status = () => props.toolState()?.status || "" - const toolInputDisplay = createMemo((): { content: string; copyText: string; language: string } | null => { + const toolInputDisplay = createMemo((): { content: string; copyText: string | null; language: string } | null => { const input = props.toolInput() if (!input || Object.keys(input).length === 0) return null + if (exceedsRetainedByteLimit(input, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { content: props.t("toolCall.input.tooLarge"), copyText: null, language: "text" } + } try { const yamlText = stringifyYaml(input) @@ -418,6 +426,7 @@ function ToolCallDetails(props: { toolName: props.toolName, instanceId: props.instanceId, sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId, t: props.t, messageVersion: messageVersionAccessor, partVersion: partVersionAccessor, @@ -435,6 +444,7 @@ function ToolCallDetails(props: { partVersion={options.partVersion} instanceId={props.instanceId} sessionId={options.sessionId} + visibilitySessionId={options.visibilitySessionId ?? props.visibilitySessionId ?? props.sessionId} onContentRendered={props.onContentRendered} forceCollapsed={options.forceCollapsed} /> @@ -475,7 +485,7 @@ function ToolCallDetails(props: { if (state?.status === "error" && state.error) { return (
- {props.t("toolCall.error.label")} {state.error} + {props.t("toolCall.error.label")} {limitToolOutputForRender(state.error)}
) } @@ -522,6 +532,18 @@ function ToolCallDetails(props: { await copyToClipboard(text) } + const copyToolInput = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const input = props.toolInput() + if (!input) return + try { + await copyToClipboard(stringifyYaml(input)) + } catch { + await copyToClipboard(JSON.stringify(input, null, 2)) + } + } + const outputWrapTitle = () => props.outputWrapEnabled() ? props.t("toolCall.diff.disableWordWrap") @@ -535,6 +557,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement wrapToggle?: () => boolean | undefined }) => ( @@ -551,18 +574,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + + @@ -644,6 +665,7 @@ function ToolCallDetails(props: { expanded: props.inputSectionExpanded, onToggle: props.toggleInputSection, copyText: () => toolInputDisplay()?.copyText, + onCopy: toolInputDisplay()?.copyText === null ? (event) => void copyToolInput(event) : undefined, copyTitle: () => props.t("toolCall.io.copyInputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"), }) @@ -887,6 +909,7 @@ export default function ToolCall(props: ToolCallProps) { toolName, instanceId: props.instanceId, sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId ?? props.sessionId, t, messageVersion: () => props.messageVersion, partVersion: () => props.partVersion, @@ -1145,6 +1168,7 @@ export default function ToolCall(props: ToolCallProps) { toolCallIdentifier={toolCallIdentifier} instanceId={props.instanceId} sessionId={props.sessionId} + visibilitySessionId={props.visibilitySessionId ?? props.sessionId} messageId={props.messageId} messageVersion={props.messageVersion} partVersion={props.partVersion} diff --git a/packages/ui/src/components/tool-call/diagnostics.ts b/packages/ui/src/components/tool-call/diagnostics.ts index 8651df697..237b35a4e 100644 --- a/packages/ui/src/components/tool-call/diagnostics.ts +++ b/packages/ui/src/components/tool-call/diagnostics.ts @@ -102,6 +102,7 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat if (!Array.isArray(list) || list.length === 0) return [] const entries: DiagnosticEntry[] = [] + const limit = 100 const normalizedPath = normalizeDiagnosticPath(key) for (let index = 0; index < list.length; index++) { const diagnostic = list[index] @@ -111,17 +112,18 @@ export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPat const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0 const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0 entries.push({ - id: `${normalizedPath}-${index}-${diagnostic.message}`, + id: String(index), severity: severityMeta.rank, tone, label: severityMeta.label, icon: severityMeta.icon, - message: diagnostic.message, + message: diagnostic.message.slice(0, 2_000), filePath: normalizedPath, displayPath: getRelativePath(normalizedPath), line, column, }) + if (entries.length >= limit) break } return entries.sort((a, b) => a.severity - b.severity) diff --git a/packages/ui/src/components/tool-call/diff-render.tsx b/packages/ui/src/components/tool-call/diff-render.tsx index 563fe9d6e..efbd79343 100644 --- a/packages/ui/src/components/tool-call/diff-render.tsx +++ b/packages/ui/src/components/tool-call/diff-render.tsx @@ -5,7 +5,7 @@ import { AlignJustify, Copy, Split, WrapText } from "lucide-solid" import type { RenderCache } from "../../types/message" import type { DiffViewMode } from "../../stores/preferences" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" -import { getRelativePath } from "./utils" +import { getRelativePath, limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils" import { getCacheEntry } from "../../lib/global-cache" import { copyToClipboard } from "../../lib/clipboard" @@ -65,6 +65,8 @@ export function createDiffContentRenderer(params: { } function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null { + const renderedDiffText = limitToolOutputForRender(payload.diffText) + const diffWasTruncated = payload.diffText.length > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT const relativePath = payload.filePath ? getRelativePath(payload.filePath) : "" const toolbarLabel = options?.label || (relativePath ? params.t("toolCall.diff.label.withPath", { path: relativePath }) @@ -100,7 +102,7 @@ export function createDiffContentRenderer(params: { const cached = getCacheEntry(cacheEntryParams) if ( cached - && cached.text === payload.diffText + && cached.text === renderedDiffText && cached.theme === themeKey && cached.mode === currentMode() && cached.wrap === currentWrap() @@ -172,12 +174,14 @@ export function createDiffContentRenderer(params: {
- {cachedHtml() ? ( + {diffWasTruncated ? ( +
{renderedDiffText}
+ ) : cachedHtml() ? ( ) : ( - {payload.diffText}}> + {renderedDiffText}}> ) => string }) { return ( 0}> @@ -62,7 +66,7 @@ export const applyPatchRenderer: ToolRenderer = { } return getToolName("apply_patch") }, - getOutputChrome({ toolState }) { + getOutputChrome({ toolState, t }) { const state = toolState() if (!state || state.status === "pending") return undefined @@ -72,6 +76,23 @@ export const applyPatchRenderer: ToolRenderer = { .map((file) => (typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : "")) .filter((diff) => diff.trim().length > 0) if (diffs.length > 0) { + if (diffs.reduce((total, diff) => total + diff.length, 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) { + return { + language: "diff", + suppressInnerHeader: false, + actions: ( + + ), + } + } return { language: "diff", copyText: diffs.join("\n"), suppressInnerHeader: false } } @@ -84,10 +105,11 @@ export const applyPatchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const payload = readToolStatePayload(state) - const files = createMemo(() => { + const allFiles = createMemo(() => { const list = (payload.metadata as any).files return Array.isArray(list) ? (list as ApplyPatchFile[]) : [] }) + const files = createMemo(() => allFiles().slice(0, APPLY_PATCH_FILE_RENDER_LIMIT)) const diagnosticsMap = createMemo(() => { const value = (payload.metadata as any).diagnostics return value && typeof value === "object" ? (value as DiagnosticsMap) : {} @@ -96,7 +118,7 @@ export const applyPatchRenderer: ToolRenderer = { if (files().length === 0) { const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null if (!fallback) return null - return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" }) + return renderMarkdown({ content: limitToolOutputForRender(fallback), size: "large", disableHighlight: state.status === "running" }) } return ( @@ -124,6 +146,9 @@ export const applyPatchRenderer: ToolRenderer = { ) }} + APPLY_PATCH_FILE_RENDER_LIMIT}> +
{t("toolCall.output.truncated")}
+
) }, diff --git a/packages/ui/src/components/tool-call/renderers/bash.tsx b/packages/ui/src/components/tool-call/renderers/bash.tsx index 36275163d..9fdec9852 100644 --- a/packages/ui/src/components/tool-call/renderers/bash.tsx +++ b/packages/ui/src/components/tool-call/renderers/bash.tsx @@ -1,7 +1,7 @@ import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolRenderer, ToolScrollHelpers } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { createStableAnsiStreamUpdater } from "../ansi-render" import { ansiToHtml, hasAnsi } from "../../../lib/ansi" @@ -99,7 +99,7 @@ function getBashCopyText(state: ToolState | undefined): string { const { input, metadata } = readToolStatePayload(state) const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const outputResult = formatUnknownForCopy( isToolStateCompleted(state) ? state.output : (isToolStateRunning(state) || isToolStateError(state)) && metadata.output @@ -122,8 +122,8 @@ function BashToolBody(props: { if (!current || current.status === "pending") return "" const { input, metadata } = readToolStatePayload(current) - const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : "" - const outputResult = formatUnknown( + const command = typeof input.command === "string" && input.command.length > 0 ? limitToolOutputForRender(`$ ${input.command}`) : "" + const outputResult = formatUnknownForRender( isToolStateCompleted(current) ? current.output : (isToolStateRunning(current) || isToolStateError(current)) && metadata.output @@ -132,10 +132,11 @@ function BashToolBody(props: { ) return [command, outputResult?.text].filter(Boolean).join("\n") }) + const renderedContent = createMemo(() => limitToolOutputForRender(joinedContent())) const finalMarkdown = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -147,7 +148,7 @@ function BashToolBody(props: { const finalAnsiHtml = createMemo(() => { const current = state() - const content = joinedContent() + const content = renderedContent() if (!current || current.status === "pending" || current.status === "running" || content.length === 0) { return null } @@ -169,7 +170,7 @@ function BashToolBody(props: {
} > - +
) diff --git a/packages/ui/src/components/tool-call/renderers/default.tsx b/packages/ui/src/components/tool-call/renderers/default.tsx index f19682675..9433bb643 100644 --- a/packages/ui/src/components/tool-call/renderers/default.tsx +++ b/packages/ui/src/components/tool-call/renderers/default.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const defaultRenderer: ToolRenderer = { @@ -16,7 +16,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForCopy(primaryOutput) if (!result) return undefined return { @@ -37,7 +37,7 @@ export const defaultRenderer: ToolRenderer = { ? metadata.output : metadata.diff ?? metadata.preview ?? input.content - const result = formatUnknown(primaryOutput) + const result = formatUnknownForRender(primaryOutput) if (!result) return null const content = ensureMarkdownContent(result.text, result.language, true) diff --git a/packages/ui/src/components/tool-call/renderers/edit.tsx b/packages/ui/src/components/tool-call/renderers/edit.tsx index b4f84fb1d..bde7cb018 100644 --- a/packages/ui/src/components/tool-call/renderers/edit.tsx +++ b/packages/ui/src/components/tool-call/renderers/edit.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -43,7 +43,8 @@ export const editRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/patch.tsx b/packages/ui/src/components/tool-call/renderers/patch.tsx index 356bba5a6..a190dfdfa 100644 --- a/packages/ui/src/components/tool-call/renderers/patch.tsx +++ b/packages/ui/src/components/tool-call/renderers/patch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, extractDiffPayload, getRelativePath, getToolName, isToolStateCompleted, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getDiffToolSearchText } from "../search-text" @@ -43,7 +43,8 @@ export const patchRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const diffText = typeof metadata.diff === "string" ? metadata.diff : null const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null - const content = ensureMarkdownContent(diffText || fallback, "diff", true) + const value = diffText || fallback + const content = ensureMarkdownContent(value ? limitToolOutputForRender(value) : value, "diff", true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) diff --git a/packages/ui/src/components/tool-call/renderers/read.tsx b/packages/ui/src/components/tool-call/renderers/read.tsx index ff37e2e85..e4b73be66 100644 --- a/packages/ui/src/components/tool-call/renderers/read.tsx +++ b/packages/ui/src/components/tool-call/renderers/read.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getReadToolSearchText } from "../search-text" @@ -56,7 +56,7 @@ export const readRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const preview = typeof metadata.preview === "string" ? metadata.preview : null const language = inferLanguageFromPath(getReadPath(input)) - const content = ensureMarkdownContent(preview, language, true) + const content = ensureMarkdownContent(preview ? limitToolOutputForRender(preview) : preview, language, true) if (!content) return null return renderMarkdown({ content, disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/renderers/skill.tsx b/packages/ui/src/components/tool-call/renderers/skill.tsx index adaa2cc66..ab9017201 100644 --- a/packages/ui/src/components/tool-call/renderers/skill.tsx +++ b/packages/ui/src/components/tool-call/renderers/skill.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName } from "../utils" import { getDefaultToolSearchText } from "../search-text" export const skillRenderer: ToolRenderer = { @@ -12,7 +12,7 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return undefined - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForCopy(state.output)?.text ?? null if (!output) return undefined return { copyText: output, suppressInnerHeader: true } }, @@ -20,7 +20,7 @@ export const skillRenderer: ToolRenderer = { const state = toolState() if (!state || state.status !== "completed") return null - const output = formatUnknown(state.output)?.text ?? null + const output = formatUnknownForRender(state.output)?.text ?? null const content = ensureMarkdownContent(output, undefined, false) if (!content) return null return
{renderMarkdown({ content })}
diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index fce9b3781..08c9d1570 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,11 +1,15 @@ -import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" +import { For, Index, Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, limitToolOutputForRender, readToolStatePayload } from "../utils" import { messageStoreBus } from "../../../stores/message-v2/bus" +import { activeInstanceId } from "../../../stores/instances" import { loadMessages } from "../../../stores/session-api" -import { loading, messagesLoaded } from "../../../stores/session-state" +import { activeSessionId, loading, messagesLoaded } from "../../../stores/session-state" +import { setVisibleSessionMemory } from "../../../stores/session-memory" import { getTaskToolSearchText } from "../search-text" +import { Copy } from "lucide-solid" +import { copyToClipboard } from "../../../lib/clipboard" interface TaskSummaryItem { id: string @@ -17,6 +21,8 @@ interface TaskSummaryItem { title?: string } +const TASK_STEP_RENDER_LIMIT = 200 + function extractSessionIdFromTaskState(state?: ToolState): string { if (!state) return "" const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} @@ -38,6 +44,7 @@ function TaskToolCallRow(props: { toolKey: string store: ReturnType sessionId: string + visibilitySessionId: string renderToolCall: NonNullable }) { const parts = createMemo(() => splitToolKey(props.toolKey)) @@ -74,6 +81,7 @@ function TaskToolCallRow(props: { messageVersion: messageVersion(), partVersion: partVersion(), sessionId: props.sessionId, + visibilitySessionId: props.visibilitySessionId, forceCollapsed: true, }) }) @@ -169,9 +177,31 @@ export const taskRenderer: ToolRenderer = { const { input } = readToolStatePayload(state) return describeTaskTitle(input) }, - renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { + getOutputChrome({ toolState, t }) { + const state = toolState() + if (!state) return undefined + const { input } = readToolStatePayload(state) + const prompt = typeof input.prompt === "string" ? input.prompt : "" + const output = state && "output" in state && typeof state.output === "string" ? state.output : null + if (prompt.length > 10_000) { + return { + actions: ( + + ), + } + } + return output ? { copyText: output } : undefined + }, + renderBody({ toolState, instanceId, visibilitySessionId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) { const store = messageStoreBus.getOrCreate(instanceId) - const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const childSessionId = createMemo(() => { const state = toolState() @@ -195,13 +225,20 @@ export const taskRenderer: ToolRenderer = { createEffect(() => { const id = childSessionId() if (!id) return - if (requestedChildLoad()) return + if (activeInstanceId() !== instanceId) return + if (activeSessionId().get(instanceId) !== visibilitySessionId) return if (childSessionLoaded()) return if (childSessionLoading()) return - setRequestedChildLoad(true) void loadMessages(instanceId, id) }) + createEffect(() => { + const id = childSessionId() + if (!id || activeInstanceId() !== instanceId || activeSessionId().get(instanceId) !== visibilitySessionId) return + setVisibleSessionMemory(instanceId, id, true) + onCleanup(() => setVisibleSessionMemory(instanceId, id, false)) + }) + const [childToolKeys, setChildToolKeys] = createSignal([]) let indexedSessionId = "" @@ -241,10 +278,10 @@ export const taskRenderer: ToolRenderer = { indexedPartCounts.clear() const nextKeys: string[] = [] - for (const messageId of messageIds) { - nextKeys.push(...scanMessageToolParts(messageId, 0)) + for (let index = messageIds.length - 1; index >= 0 && nextKeys.length < TASK_STEP_RENDER_LIMIT; index -= 1) { + nextKeys.unshift(...scanMessageToolParts(messageIds[index], 0)) } - setChildToolKeys(nextKeys) + setChildToolKeys(nextKeys.slice(-TASK_STEP_RENDER_LIMIT)) } createEffect(() => { @@ -310,7 +347,7 @@ export const taskRenderer: ToolRenderer = { indexedMessageTail = messageIds[messageIds.length - 1] ?? "" if (appendedKeys.length > 0) { - setChildToolKeys((prev) => [...prev, ...appendedKeys]) + setChildToolKeys((prev) => [...prev, ...appendedKeys].slice(-TASK_STEP_RENDER_LIMIT)) } }) }) @@ -319,14 +356,14 @@ export const taskRenderer: ToolRenderer = { if (!state) return null const { input } = readToolStatePayload(state) const prompt = typeof input.prompt === "string" ? input.prompt : null - return ensureMarkdownContent(prompt, undefined, false) + return ensureMarkdownContent(prompt ? limitToolOutputForRender(prompt) : prompt, undefined, false) }) const outputContent = createMemo(() => { const state = toolState() if (!state) return null const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null - return ensureMarkdownContent(output, undefined, false) + return ensureMarkdownContent(output ? limitToolOutputForRender(output) : output, undefined, false) }) const agentLabel = createMemo(() => { @@ -372,7 +409,7 @@ export const taskRenderer: ToolRenderer = { const { metadata } = readToolStatePayload(state) const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] - return summary.map((entry, index) => { + return summary.slice(-TASK_STEP_RENDER_LIMIT).map((entry, index) => { const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown" const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {} @@ -420,10 +457,13 @@ export const taskRenderer: ToolRenderer = {
{t("toolCall.task.sections.steps")}
+ = TASK_STEP_RENDER_LIMIT || legacyItems().length >= TASK_STEP_RENDER_LIMIT}> +
{t("toolCall.output.truncated")}
+
0} fallback={ @@ -483,6 +523,7 @@ export const taskRenderer: ToolRenderer = { toolKey={key()} store={store} sessionId={childSessionId()} + visibilitySessionId={visibilitySessionId} renderToolCall={render()} /> )} diff --git a/packages/ui/src/components/tool-call/renderers/webfetch.tsx b/packages/ui/src/components/tool-call/renderers/webfetch.tsx index 59aa5645a..246cfad8b 100644 --- a/packages/ui/src/components/tool-call/renderers/webfetch.tsx +++ b/packages/ui/src/components/tool-call/renderers/webfetch.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, formatUnknown, getToolName, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, formatUnknownForCopy, formatUnknownForRender, getToolName, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWebfetchToolSearchText } from "../search-text" @@ -21,7 +21,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return undefined const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForCopy( state.status === "completed" ? state.output : metadata.output, @@ -40,7 +40,7 @@ export const webfetchRenderer: ToolRenderer = { if (!state || state.status === "pending") return null const { metadata } = readToolStatePayload(state) - const result = formatUnknown( + const result = formatUnknownForRender( state.status === "completed" ? state.output : metadata.output, diff --git a/packages/ui/src/components/tool-call/renderers/write.tsx b/packages/ui/src/components/tool-call/renderers/write.tsx index 9a7f3d540..f72c0438f 100644 --- a/packages/ui/src/components/tool-call/renderers/write.tsx +++ b/packages/ui/src/components/tool-call/renderers/write.tsx @@ -1,5 +1,5 @@ import type { ToolRenderer } from "../types" -import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, readToolStatePayload } from "../utils" +import { ensureMarkdownContent, getRelativePath, getToolName, inferLanguageFromPath, limitToolOutputForRender, readToolStatePayload } from "../utils" import { tGlobal } from "../../../lib/i18n" import { getWriteToolSearchText } from "../search-text" @@ -35,7 +35,7 @@ export const writeRenderer: ToolRenderer = { const { metadata, input } = readToolStatePayload(state) const contentValue = typeof input.content === "string" ? input.content : metadata.content const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const content = ensureMarkdownContent(contentValue ?? null, inferLanguageFromPath(filePath), true) + const content = ensureMarkdownContent(typeof contentValue === "string" ? limitToolOutputForRender(contentValue) : null, inferLanguageFromPath(filePath), true) if (!content) return null return renderMarkdown({ content, size: "large", disableHighlight: state.status === "running" }) }, diff --git a/packages/ui/src/components/tool-call/search-text.ts b/packages/ui/src/components/tool-call/search-text.ts index b50cf4ef2..b906be102 100644 --- a/packages/ui/src/components/tool-call/search-text.ts +++ b/packages/ui/src/components/tool-call/search-text.ts @@ -4,19 +4,31 @@ import { isToolStateCompleted, isToolStateError, isToolStateRunning, + limitToolOutputForRender, readToolStatePayload, } from "./utils" +import { exceedsRetainedByteLimit } from "../../lib/session-memory-budget" type QuestionOption = { label?: unknown; description?: unknown } type QuestionPrompt = { header?: unknown; question?: unknown; options?: unknown; multiple?: unknown; answer?: unknown } +const TOOL_SEARCH_CHARACTER_LIMIT = 10_000 +const searchSizes = new WeakMap() function appendString(values: string[], value: unknown) { - if (typeof value === "string" && value.trim().length > 0) values.push(value) + if (typeof value !== "string" || value.trim().length === 0) return + const used = searchSizes.get(values) ?? 0 + const remaining = TOOL_SEARCH_CHARACTER_LIMIT - used + if (remaining <= 0) return + const text = limitToolOutputForRender(value).slice(0, remaining) + values.push(text) + searchSizes.set(values, used + text.length) } function appendFormatted(values: string[], value: unknown) { + if ((searchSizes.get(values) ?? 0) >= TOOL_SEARCH_CHARACTER_LIMIT) return + if (exceedsRetainedByteLimit(value, 10_000)) return const result = formatUnknown(value) - if (result?.text.trim()) values.push(result.text) + if (result?.text.trim()) appendString(values, result.text) } function appendBaseToolText(values: string[], context: ToolSearchTextContext) { diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index 496337877..db6d40ded 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -57,6 +57,7 @@ export interface ToolRendererContext { toolName: Accessor instanceId: string sessionId: string + visibilitySessionId: string t: (key: string, params?: Record) => string messageVersion?: Accessor partVersion?: Accessor @@ -73,6 +74,7 @@ export interface ToolRendererContext { messageVersion?: number partVersion?: number sessionId: string + visibilitySessionId?: string forceCollapsed?: boolean }) => JSXElement | null outputWrapEnabled?: Accessor diff --git a/packages/ui/src/components/tool-call/utils.test.ts b/packages/ui/src/components/tool-call/utils.test.ts new file mode 100644 index 000000000..772d18f14 --- /dev/null +++ b/packages/ui/src/components/tool-call/utils.test.ts @@ -0,0 +1,12 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./utils.ts" + +test("tool output rendering keeps bounded head and tail content", () => { + const text = `HEAD${"x".repeat(20_000)}TAIL` + const rendered = limitToolOutputForRender(text) + assert.ok(rendered.length < text.length) + assert.ok(rendered.length < TOOL_OUTPUT_RENDER_CHARACTER_LIMIT + 100) + assert.ok(rendered.startsWith("HEAD")) + assert.ok(rendered.endsWith("TAIL")) +}) diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 1ea469b5e..dc75ba019 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -4,6 +4,7 @@ import type { ToolState } from "@opencode-ai/sdk/v2" import type { DiffPayload } from "./types" import { getLogger } from "../../lib/logger" import { tGlobal } from "../../lib/i18n" +import { exceedsRetainedByteLimit } from "../../lib/session-memory-budget" const log = getLogger("session") @@ -12,6 +13,13 @@ export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateComplete export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError export const diffCapableTools = new Set(["edit", "patch"]) +export const TOOL_OUTPUT_RENDER_CHARACTER_LIMIT = 10_000 + +export function limitToolOutputForRender(text: string): string { + if (text.length <= TOOL_OUTPUT_RENDER_CHARACTER_LIMIT) return text + const half = Math.floor(TOOL_OUTPUT_RENDER_CHARACTER_LIMIT / 2) + return `${text.slice(0, half)}\n\n${tGlobal("toolCall.output.truncated")}\n\n${text.slice(-half)}` +} export function isToolStateRunning(state: ToolState): state is ToolStateRunning { return state.status === "running" @@ -150,6 +158,19 @@ export function formatUnknown(value: unknown): { text: string; language?: string return null } +export function formatUnknownForRender(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) { + return { text: tGlobal("toolCall.output.tooLarge") } + } + const result = formatUnknown(value) + return result ? { ...result, text: limitToolOutputForRender(result.text) } : null +} + +export function formatUnknownForCopy(value: unknown): { text: string; language?: string } | null { + if (typeof value !== "string" && exceedsRetainedByteLimit(value, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)) return null + return formatUnknown(value) +} + export function inferLanguageFromPath(path?: string): string | undefined { return getLanguageFromPath(path || "") } @@ -236,13 +257,14 @@ export function buildToolSpeechText(options: { }): string { const sections: string[] = [] - if (options.title.trim()) { - sections.push(options.title.trim()) + const title = limitToolOutputForRender(options.title).trim() + if (title) { + sections.push(title) } const { input, output } = readToolStatePayload(options.state) - const formattedInput = formatUnknown(input) - const formattedOutput = formatUnknown(output) + const formattedInput = formatUnknownForRender(input) + const formattedOutput = formatUnknownForRender(output) if (formattedInput?.text?.trim()) { sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`) @@ -252,13 +274,14 @@ export function buildToolSpeechText(options: { sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`) } - if (options.state?.status === "error" && options.state.error?.trim()) { - sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`) + const error = options.state?.status === "error" ? limitToolOutputForRender(options.state.error ?? "").trim() : "" + if (error) { + sections.push(`${options.t("toolCall.error.label")} ${error}`) } if (sections.length === 1 && options.state?.status === "pending") { sections.push(options.t("toolCall.pending.waitingToRun")) } - return sections.join("\n\n").trim() + return limitToolOutputForRender(sections.join("\n\n").trim()) } diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 8a7f17ad4..86982c83d 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -76,6 +76,7 @@ export interface VirtualFollowListProps { resolveAutoPinHoldElement?: HoldTargetElementResolver autoPinHoldTopThresholdPx?: number suspendAutoPinToBottom?: Accessor + shift?: Accessor renderBeforeItems?: Accessor renderOverlay?: Accessor scrollToTopAriaLabel?: Accessor @@ -755,6 +756,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { scrollRef={scrollElement()} data={props.items()} bufferSize={props.overscanPx ?? 400} + shift={props.shift?.() ?? false} onScroll={handleScroll} > {(item, index) => { diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index e11c97650..dcb26dd89 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -15,12 +15,13 @@ import { showAlertDialog } from "../../stores/alerts" import type { Instance } from "../../types/instance" import type { MessageRecord } from "../../stores/message-v2/types" import { messageStoreBus } from "../../stores/message-v2/bus" -import { cleanupBlankSessions } from "../../stores/session-state" +import { cleanupBlankSessions, invalidateSessionMessageLoad } from "../../stores/session-state" import { getLogger } from "../logger" import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" +import { invalidateSessionMessageCache } from "../../stores/session-message-cache" const log = getLogger("actions") @@ -344,6 +345,11 @@ export function useCommands(options: UseCommandsOptions) { }), "session.revert", ) + if (activeInstance() !== instance) return + if (store.getSessionRevert(sessionId)?.messageID !== messageID) { + invalidateSessionMessageLoad(instance.id, sessionId) + invalidateSessionMessageCache(instance.id, sessionId) + } if (!restoredText) { const fallbackRecord = store.getMessage(messageID) diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 49794fa81..c9475d15c 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startverhalten", "settings.appearance.startup.subtitle": "Lege fest, was dieses Gerät beim Start von CodeNomad wiederherstellt.", "settings.appearance.startup.restore.title": "Vorherigen Zustand wiederherstellen", - "settings.appearance.startup.restore.subtitle": "Arbeitsbereichs- und Sidecar-Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen sowie Scrollpositionen, Panel-Layout, Fensterposition und Zoom wiederherstellen.", + "settings.appearance.startup.restore.subtitle": "Tabs, aktive Sitzungen und nicht gesendete Nachrichten erneut öffnen, das Layout wiederherstellen und aktuelle Sitzungsnachrichten für schnelleres Öffnen auf diesem Gerät zwischenspeichern.", "settings.appearance.startup.clear.title": "Gespeicherter Startzustand", - "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Scrollpositionen und das Panel-Layout entfernen. CodeNomad- und OpenCode-Daten werden nicht gelöscht.", + "settings.appearance.startup.clear.subtitle": "Gespeicherte Tabs, Entwürfe, Layouts und zwischengespeicherte Sitzungsnachrichten von diesem Gerät entfernen. OpenCode-Unterhaltungen werden nicht gelöscht.", "settings.appearance.startup.clear.action": "Gespeicherten Zustand löschen", "settings.appearance.startup.clearSuccess": "Gespeicherter Startzustand wurde gelöscht.", "settings.appearance.startup.clearError": "Der gespeicherte Startzustand konnte nicht gelöscht werden.", diff --git a/packages/ui/src/lib/i18n/messages/de/toolCall.ts b/packages/ui/src/lib/i18n/messages/de/toolCall.ts index c736663f8..670d344c6 100644 --- a/packages/ui/src/lib/i18n/messages/de/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/de/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Verzeichnis wird aufgelistet...", "toolCall.renderer.bash.title.timeout": "Zeitüberschreitung: {timeout}", + "toolCall.output.truncated": "[Ausgabe für die Darstellung gekürzt; kopieren Sie sie für die vollständige Ausgabe]", + "toolCall.input.tooLarge": "Die Eingabe wird nicht dargestellt, da sie zu groß ist.", + "toolCall.output.tooLarge": "Die strukturierte Ausgabe wird nicht dargestellt, da sie zu groß ist.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 7e79f5e06..47ba5e7ce 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Startup", "settings.appearance.startup.subtitle": "Choose what this device restores when CodeNomad starts.", "settings.appearance.startup.restore.title": "Restore previous state", - "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages, and restore scroll positions, panel layout, window position, and zoom.", + "settings.appearance.startup.restore.subtitle": "Reopen workspace and sidecar tabs, active sessions, and unsent messages; restore layout; and cache recent session messages on this device for faster reopening.", "settings.appearance.startup.clear.title": "Saved startup state", - "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, scroll positions, and panel layout. CodeNomad and OpenCode data are not deleted.", + "settings.appearance.startup.clear.subtitle": "Remove saved tabs, drafts, layout, and cached session messages from this device. OpenCode conversation data is not deleted.", "settings.appearance.startup.clear.action": "Clear saved state", "settings.appearance.startup.clearSuccess": "Saved startup state cleared.", "settings.appearance.startup.clearError": "Could not clear the saved startup state.", diff --git a/packages/ui/src/lib/i18n/messages/en/toolCall.ts b/packages/ui/src/lib/i18n/messages/en/toolCall.ts index 8bf7bec07..c7659c845 100644 --- a/packages/ui/src/lib/i18n/messages/en/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/en/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listing directory...", "toolCall.renderer.bash.title.timeout": "Timeout: {timeout}", + "toolCall.output.truncated": "[Output truncated for rendering; copy to access the full output]", + "toolCall.input.tooLarge": "Input omitted from rendering because it is too large.", + "toolCall.output.tooLarge": "Structured output omitted from rendering because it is too large.", "toolCall.renderer.read.detail.offset": "Offset: {offset}", "toolCall.renderer.read.detail.limit": "Limit: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 268c30623..4417d8396 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Inicio", "settings.appearance.startup.subtitle": "Elige qué restaura este dispositivo al iniciar CodeNomad.", "settings.appearance.startup.restore.title": "Restaurar el estado anterior", - "settings.appearance.startup.restore.subtitle": "Volver a abrir las pestañas de espacios de trabajo y sidecars, las sesiones activas y los mensajes no enviados, y restaurar las posiciones de desplazamiento, el diseño de paneles, la posición de la ventana y el zoom.", + "settings.appearance.startup.restore.subtitle": "Volver a abrir pestañas, sesiones activas y mensajes no enviados, restaurar el diseño y guardar en caché los mensajes recientes en este dispositivo para abrirlos más rápido.", "settings.appearance.startup.clear.title": "Estado de inicio guardado", - "settings.appearance.startup.clear.subtitle": "Elimina pestañas, borradores, posiciones de desplazamiento y el diseño de paneles guardados. No se eliminan datos de CodeNomad ni de OpenCode.", + "settings.appearance.startup.clear.subtitle": "Elimina de este dispositivo las pestañas, borradores, diseños y mensajes de sesión en caché. No se eliminan las conversaciones de OpenCode.", "settings.appearance.startup.clear.action": "Borrar estado guardado", "settings.appearance.startup.clearSuccess": "Se borró el estado de inicio guardado.", "settings.appearance.startup.clearError": "No se pudo borrar el estado de inicio guardado.", diff --git a/packages/ui/src/lib/i18n/messages/es/toolCall.ts b/packages/ui/src/lib/i18n/messages/es/toolCall.ts index ea206440e..3cd68824e 100644 --- a/packages/ui/src/lib/i18n/messages/es/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/es/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Listando directorio...", "toolCall.renderer.bash.title.timeout": "Tiempo de espera: {timeout}", + "toolCall.output.truncated": "[Salida truncada para la visualización; cópiala para acceder a la salida completa]", + "toolCall.input.tooLarge": "La entrada no se muestra porque es demasiado grande.", + "toolCall.output.tooLarge": "La salida estructurada no se muestra porque es demasiado grande.", "toolCall.renderer.read.detail.offset": "Desplazamiento: {offset}", "toolCall.renderer.read.detail.limit": "Límite: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 65c924f73..66a24e130 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Démarrage", "settings.appearance.startup.subtitle": "Choisissez ce que cet appareil restaure au démarrage de CodeNomad.", "settings.appearance.startup.restore.title": "Restaurer l’état précédent", - "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets d’espaces de travail et de sidecars, les sessions actives et les messages non envoyés, et restaurer les positions de défilement, la disposition des panneaux, la position de la fenêtre et le zoom.", + "settings.appearance.startup.restore.subtitle": "Rouvrir les onglets, les sessions actives et les messages non envoyés, restaurer la disposition et mettre en cache les messages récents sur cet appareil pour accélérer leur réouverture.", "settings.appearance.startup.clear.title": "État de démarrage enregistré", - "settings.appearance.startup.clear.subtitle": "Supprime les onglets, brouillons, positions de défilement et la disposition des panneaux enregistrés. Les données CodeNomad et OpenCode ne sont pas supprimées.", + "settings.appearance.startup.clear.subtitle": "Supprime de cet appareil les onglets, brouillons, dispositions et messages de session mis en cache. Les conversations OpenCode ne sont pas supprimées.", "settings.appearance.startup.clear.action": "Effacer l’état enregistré", "settings.appearance.startup.clearSuccess": "L’état de démarrage enregistré a été effacé.", "settings.appearance.startup.clearError": "Impossible d’effacer l’état de démarrage enregistré.", diff --git a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts index e706e641f..5ab53393f 100644 --- a/packages/ui/src/lib/i18n/messages/fr/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/fr/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Liste du répertoire...", "toolCall.renderer.bash.title.timeout": "Délai : {timeout}", + "toolCall.output.truncated": "[Sortie tronquée pour l’affichage ; copiez-la pour accéder à la sortie complète]", + "toolCall.input.tooLarge": "Entrée omise de l’affichage car elle est trop volumineuse.", + "toolCall.output.tooLarge": "Sortie structurée omise de l’affichage car elle est trop volumineuse.", "toolCall.renderer.read.detail.offset": "Décalage : {offset}", "toolCall.renderer.read.detail.limit": "Limite : {limit}", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index a7f9c7fb6..b571a02d5 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "הפעלה", "settings.appearance.startup.subtitle": "בחר מה ישוחזר במכשיר זה בעת הפעלת CodeNomad.", "settings.appearance.startup.restore.title": "שחזור המצב הקודם", - "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות סביבות עבודה ו-sidecar, הפעלות פעילות והודעות שלא נשלחו, ושחזור מיקומי גלילה, פריסת חלוניות, מיקום החלון ורמת התקריב.", + "settings.appearance.startup.restore.subtitle": "פתיחה מחדש של כרטיסיות, הפעלות פעילות והודעות שלא נשלחו, שחזור הפריסה ושמירת הודעות אחרונות במטמון במכשיר זה לפתיחה מהירה יותר.", "settings.appearance.startup.clear.title": "מצב הפעלה שמור", - "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, מיקומי גלילה ופריסת חלוניות שנשמרו. נתוני CodeNomad ו-OpenCode לא יימחקו.", + "settings.appearance.startup.clear.subtitle": "הסרת כרטיסיות, טיוטות, פריסות והודעות הפעלה שמורות במטמון ממכשיר זה. שיחות OpenCode לא יימחקו.", "settings.appearance.startup.clear.action": "נקה מצב שמור", "settings.appearance.startup.clearSuccess": "מצב ההפעלה השמור נוקה.", "settings.appearance.startup.clearError": "לא ניתן לנקות את מצב ההפעלה השמור.", diff --git a/packages/ui/src/lib/i18n/messages/he/toolCall.ts b/packages/ui/src/lib/i18n/messages/he/toolCall.ts index 678092bc6..67e98a582 100644 --- a/packages/ui/src/lib/i18n/messages/he/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/he/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "מפרט ספרייה...", "toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}", + "toolCall.output.truncated": "[הפלט קוצר לצורך תצוגה; יש להעתיק כדי לגשת לפלט המלא]", + "toolCall.input.tooLarge": "הקלט לא מוצג מכיוון שהוא גדול מדי.", + "toolCall.output.tooLarge": "הפלט המובנה לא מוצג מכיוון שהוא גדול מדי.", "toolCall.renderer.read.detail.offset": "היסט: {offset}", "toolCall.renderer.read.detail.limit": "מגבלה: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index 6b266a108..f67f47a6b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "起動", "settings.appearance.startup.subtitle": "CodeNomad の起動時にこのデバイスで復元する内容を選択します。", "settings.appearance.startup.restore.title": "前回の状態を復元", - "settings.appearance.startup.restore.subtitle": "ワークスペースとサイドカーのタブ、アクティブなセッション、未送信メッセージを再度開き、スクロール位置、パネル配置、ウィンドウ位置、ズームを復元します。", + "settings.appearance.startup.restore.subtitle": "タブ、アクティブなセッション、未送信メッセージを再度開き、配置を復元し、最近のセッションメッセージをこのデバイスにキャッシュして再表示を高速化します。", "settings.appearance.startup.clear.title": "保存された起動状態", - "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、スクロール位置、パネル配置を削除します。CodeNomad と OpenCode のデータは削除されません。", + "settings.appearance.startup.clear.subtitle": "保存されたタブ、下書き、配置、キャッシュ済みセッションメッセージをこのデバイスから削除します。OpenCode の会話は削除されません。", "settings.appearance.startup.clear.action": "保存状態を消去", "settings.appearance.startup.clearSuccess": "保存された起動状態を消去しました。", "settings.appearance.startup.clearError": "保存された起動状態を消去できませんでした。", diff --git a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts index 681d67f72..67624feb6 100644 --- a/packages/ui/src/lib/i18n/messages/ja/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ja/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "ディレクトリ一覧を取得中...", "toolCall.renderer.bash.title.timeout": "タイムアウト: {timeout}", + "toolCall.output.truncated": "[表示用に出力を省略しました。完全な出力にアクセスするにはコピーしてください]", + "toolCall.input.tooLarge": "入力が大きすぎるため表示を省略しました。", + "toolCall.output.tooLarge": "構造化出力が大きすぎるため表示を省略しました。", "toolCall.renderer.read.detail.offset": "オフセット: {offset}", "toolCall.renderer.read.detail.limit": "上限: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index cbfbb0953..dd5eae41a 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "सुरुआत", "settings.appearance.startup.subtitle": "CodeNomad सुरु हुँदा यस यन्त्रमा के पुनर्स्थापना गर्ने छान्नुहोस्।", "settings.appearance.startup.restore.title": "अघिल्लो अवस्था पुनर्स्थापना गर्नुहोस्", - "settings.appearance.startup.restore.subtitle": "कार्यस्थान र साइडकार ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, र स्क्रोल स्थिति, प्यानल लेआउट, विन्डोको स्थान र जुम पुनर्स्थापना गर्नुहोस्।", + "settings.appearance.startup.restore.subtitle": "ट्याब, सक्रिय सत्र र नपठाइएका सन्देशहरू फेरि खोल्नुहोस्, लेआउट पुनर्स्थापना गर्नुहोस् र छिटो खोल्न हालका सत्र सन्देशहरू यस यन्त्रमा क्यास गर्नुहोस्।", "settings.appearance.startup.clear.title": "सुरक्षित सुरुआत अवस्था", - "settings.appearance.startup.clear.subtitle": "सुरक्षित ट्याब, मस्यौदा, स्क्रोल स्थिति र प्यानल लेआउट हटाउनुहोस्। CodeNomad र OpenCode का डेटा मेटिँदैनन्।", + "settings.appearance.startup.clear.subtitle": "यस यन्त्रबाट सुरक्षित ट्याब, मस्यौदा, लेआउट र क्यास गरिएका सत्र सन्देशहरू हटाउनुहोस्। OpenCode कुराकानीहरू मेटिँदैनन्।", "settings.appearance.startup.clear.action": "सुरक्षित अवस्था खाली गर्नुहोस्", "settings.appearance.startup.clearSuccess": "सुरक्षित सुरुआत अवस्था खाली गरियो।", "settings.appearance.startup.clearError": "सुरक्षित सुरुआत अवस्था खाली गर्न सकिएन।", diff --git a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts index ad2b2a8c7..51356f933 100644 --- a/packages/ui/src/lib/i18n/messages/ne/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ne/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "डाइरेक्टरी सूचीबद्ध गर्दै...", "toolCall.renderer.bash.title.timeout": "समय समाप्त: {timeout}", + "toolCall.output.truncated": "[प्रदर्शनका लागि आउटपुट छोट्याइएको छ; पूर्ण आउटपुटका लागि प्रतिलिपि गर्नुहोस्]", + "toolCall.input.tooLarge": "इनपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", + "toolCall.output.tooLarge": "संरचित आउटपुट धेरै ठूलो भएकाले प्रदर्शन गरिएको छैन।", "toolCall.renderer.read.detail.offset": "अफसेट: {offset}", "toolCall.renderer.read.detail.limit": "सीमा: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index d364ab5a6..4c49cc0a5 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "Запуск", "settings.appearance.startup.subtitle": "Выберите, что восстанавливать на этом устройстве при запуске CodeNomad.", "settings.appearance.startup.restore.title": "Восстанавливать предыдущее состояние", - "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки рабочих пространств и sidecar, активные сессии и неотправленные сообщения, а также восстанавливать позиции прокрутки, расположение панелей, положение окна и масштаб.", + "settings.appearance.startup.restore.subtitle": "Повторно открывать вкладки, активные сессии и неотправленные сообщения, восстанавливать макет и кэшировать недавние сообщения сессий на этом устройстве для быстрого открытия.", "settings.appearance.startup.clear.title": "Сохранённое состояние запуска", - "settings.appearance.startup.clear.subtitle": "Удалить сохранённые вкладки, черновики, позиции прокрутки и расположение панелей. Данные CodeNomad и OpenCode не удаляются.", + "settings.appearance.startup.clear.subtitle": "Удалить с этого устройства вкладки, черновики, макеты и кэшированные сообщения сессий. Диалоги OpenCode не удаляются.", "settings.appearance.startup.clear.action": "Очистить сохранённое состояние", "settings.appearance.startup.clearSuccess": "Сохранённое состояние запуска очищено.", "settings.appearance.startup.clearError": "Не удалось очистить сохранённое состояние запуска.", diff --git a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts index 7f18981ff..d84160e15 100644 --- a/packages/ui/src/lib/i18n/messages/ru/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/ru/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "Просмотр каталога…", "toolCall.renderer.bash.title.timeout": "Таймаут: {timeout}", + "toolCall.output.truncated": "[Вывод сокращён для отображения; скопируйте его для доступа к полному выводу]", + "toolCall.input.tooLarge": "Ввод не отображается, поскольку он слишком большой.", + "toolCall.output.tooLarge": "Структурированный вывод не отображается, поскольку он слишком большой.", "toolCall.renderer.read.detail.offset": "Смещение: {offset}", "toolCall.renderer.read.detail.limit": "Лимит: {limit}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 1eed86ce0..93b05b88c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -129,9 +129,9 @@ export const settingsMessages = { "settings.appearance.startup.title": "启动", "settings.appearance.startup.subtitle": "选择 CodeNomad 启动时在此设备上恢复的内容。", "settings.appearance.startup.restore.title": "恢复上次状态", - "settings.appearance.startup.restore.subtitle": "重新打开工作区和边栏标签页、活动会话和未发送的消息,并恢复滚动位置、面板布局、窗口位置和缩放级别。", + "settings.appearance.startup.restore.subtitle": "重新打开标签页、活动会话和未发送的消息,恢复布局,并在此设备上缓存最近的会话消息以便更快重新打开。", "settings.appearance.startup.clear.title": "已保存的启动状态", - "settings.appearance.startup.clear.subtitle": "移除已保存的标签页、草稿、滚动位置和面板布局。不会删除 CodeNomad 或 OpenCode 数据。", + "settings.appearance.startup.clear.subtitle": "从此设备移除已保存的标签页、草稿、布局和缓存的会话消息。不会删除 OpenCode 对话。", "settings.appearance.startup.clear.action": "清除已保存状态", "settings.appearance.startup.clearSuccess": "已清除保存的启动状态。", "settings.appearance.startup.clearError": "无法清除保存的启动状态。", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts index 07641548e..00621c696 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/toolCall.ts @@ -56,6 +56,9 @@ export const toolCallMessages = { "toolCall.renderer.action.listingDirectory": "正在列出目录...", "toolCall.renderer.bash.title.timeout": "超时:{timeout}", + "toolCall.output.truncated": "[输出已截断以便显示;复制即可访问完整输出]", + "toolCall.input.tooLarge": "输入内容过大,已省略显示。", + "toolCall.output.tooLarge": "结构化输出过大,已省略显示。", "toolCall.renderer.read.detail.offset": "偏移:{offset}", "toolCall.renderer.read.detail.limit": "限制:{limit}", diff --git a/packages/ui/src/lib/session-memory-budget.test.ts b/packages/ui/src/lib/session-memory-budget.test.ts new file mode 100644 index 000000000..e85923150 --- /dev/null +++ b/packages/ui/src/lib/session-memory-budget.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { estimateRetainedBytes, exceedsRetainedByteLimit, selectSessionMemoryEvictions } from "./session-memory-budget.ts" + +test("session memory eviction applies one byte budget across workspaces and subagents", () => { + const entries = Array.from({ length: 5 }, (_, workspace) => [ + { key: `${workspace}:parent`, byteSize: 8, lastTouched: workspace * 2 + 1, protected: workspace === 4 }, + { key: `${workspace}:subagent`, byteSize: 8, lastTouched: workspace * 2 + 2, protected: false }, + ]).flat() + + const evictions = selectSessionMemoryEvictions(entries, 40) + assert.deepEqual(evictions, ["0:parent", "0:subagent", "1:parent", "1:subagent", "2:parent"]) + assert.equal(evictions.includes("4:parent"), false) +}) + +test("session memory eviction favors old large sessions and permits protected overage", () => { + assert.deepEqual(selectSessionMemoryEvictions([ + { key: "visible", byteSize: 12, lastTouched: 3, protected: true }, + { key: "old-large", byteSize: 10, lastTouched: 1, protected: false }, + { key: "new-small", byteSize: 2, lastTouched: 2, protected: false }, + ], 12), ["old-large", "new-small"]) + assert.deepEqual(selectSessionMemoryEvictions([{ key: "visible", byteSize: 20, lastTouched: 1, protected: true }], 10), []) +}) + +test("retained byte estimates handle cycles without allocating serialized copies", () => { + const value: { text: string; self?: unknown } = { text: "hello" } + value.self = value + assert.ok(estimateRetainedBytes(value) >= 26) + assert.equal(exceedsRetainedByteLimit(Array.from({ length: 1_000 }, () => ({})), 10_000), true) +}) diff --git a/packages/ui/src/lib/session-memory-budget.ts b/packages/ui/src/lib/session-memory-budget.ts new file mode 100644 index 000000000..61709dca9 --- /dev/null +++ b/packages/ui/src/lib/session-memory-budget.ts @@ -0,0 +1,62 @@ +export const MAX_HOT_SESSION_MESSAGE_BYTES = 64 * 1024 * 1024 + +export interface SessionMemoryEntry { + key: string + byteSize: number + lastTouched: number + protected: boolean +} + +function measureRetainedBytes(value: unknown, limit: number): number { + const seen = new WeakSet() + const pending: unknown[] = [value] + let total = 0 + while (pending.length > 0 && total <= limit) { + const current = pending.pop() + if (typeof current === "string") total += current.length * 2 + 16 + else if (typeof current === "number" || typeof current === "bigint") total += 8 + else if (typeof current === "boolean") total += 4 + else if (current && typeof current === "object") { + if (ArrayBuffer.isView(current)) { + total += current.byteLength + continue + } + if (seen.has(current)) continue + seen.add(current) + total += Array.isArray(current) ? 24 + current.length * 8 : 32 + for (const key in current) { + if (!Object.prototype.hasOwnProperty.call(current, key)) continue + total += key.length * 2 + 8 + if (total > limit) break + pending.push((current as Record)[key]) + } + } + } + return total +} + +export function estimateRetainedBytes(value: unknown, limit = Number.POSITIVE_INFINITY): number { + return measureRetainedBytes(value, limit) +} + +export function exceedsRetainedByteLimit(value: unknown, limit: number): boolean { + return measureRetainedBytes(value, limit) > limit +} + +export function selectSessionMemoryEvictions( + entries: readonly SessionMemoryEntry[], + byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES, +): string[] { + let total = entries.reduce((sum, entry) => sum + Math.max(0, entry.byteSize), 0) + if (total <= byteLimit) return [] + const candidates = entries + .filter((entry) => !entry.protected) + .sort((left, right) => left.lastTouched - right.lastTouched || right.byteSize - left.byteSize || left.key.localeCompare(right.key)) + const evictions: string[] = [] + for (const entry of candidates) { + if (total <= byteLimit) break + evictions.push(entry.key) + total -= Math.max(0, entry.byteSize) + } + return evictions +} diff --git a/packages/ui/src/lib/session-message-cache.test.ts b/packages/ui/src/lib/session-message-cache.test.ts new file mode 100644 index 000000000..a62c79c6f --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createSessionMessageCacheKey, prepareSessionMessageCache, selectSessionMessageCacheEvictions } from "./session-message-cache.ts" + +test("session message cache keys normalize path separators", () => { + assert.equal(createSessionMessageCacheKey("C:\\work\\repo\\", "session-1"), "C:/work/repo\u0000session-1") +}) + +test("session message cache evicts the oldest entries to satisfy byte and count limits", () => { + const entries = [ + { key: "old", byteSize: 5, savedAt: 1, snapshotId: "1", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "middle", byteSize: 4, savedAt: 2, snapshotId: "2", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + { key: "new", byteSize: 3, savedAt: 3, snapshotId: "3", messageIds: [], startIndex: 0, totalCount: 0, complete: true }, + ] + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 7, 3), ["old"]) + assert.deepEqual(selectSessionMessageCacheEvictions(entries, 20, 2), ["old"]) +}) + +test("session message cache retains the newest contiguous messages within its budget", () => { + const messages = ["one", "two", "three"].map((id) => ({ info: { id }, parts: [{ type: "text", text: id.repeat(10) }] })) + const byteLimit = messages.slice(1).reduce((total, message) => total + JSON.stringify(message).length * 2, 0) + const prepared = prepareSessionMessageCache("session", messages, "snapshot", byteLimit, 1) + assert.deepEqual(prepared?.manifest.messageIds, ["two", "three"]) + assert.equal(prepared?.manifest.startIndex, 1) + assert.equal(prepared?.manifest.complete, false) + assert.equal(prepared?.records.length, 2) +}) + +test("session message cache stores an authoritative empty manifest", () => { + const prepared = prepareSessionMessageCache("session", [], "snapshot", 100, 1) + assert.deepEqual(prepared?.manifest.messageIds, []) + assert.equal(prepared?.manifest.complete, true) + assert.equal(prepared?.manifest.totalCount, 0) +}) diff --git a/packages/ui/src/lib/session-message-cache.ts b/packages/ui/src/lib/session-message-cache.ts new file mode 100644 index 000000000..c58ce1c74 --- /dev/null +++ b/packages/ui/src/lib/session-message-cache.ts @@ -0,0 +1,483 @@ +const DATABASE_NAME = "codenomad-session-messages" +const DATABASE_VERSION = 2 +const MANIFEST_STORE = "session-manifests" +const MESSAGE_STORE = "session-messages" +const SESSION_INDEX = "by-session" + +export const DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE = 50 +export const MAX_SESSION_MESSAGE_CACHE_BYTES = 16 * 1024 * 1024 +export const MAX_TOTAL_MESSAGE_CACHE_BYTES = 64 * 1024 * 1024 +export const MAX_SESSION_MESSAGE_CACHE_ENTRIES = 64 + +export interface SessionMessageCacheManifest { + key: string + snapshotId: string + messageIds: string[] + startIndex: number + totalCount: number + complete: boolean + byteSize: number + savedAt: number +} + +interface SessionMessageCacheRecord { + sessionKey: string + snapshotId: string + messageId: string + ordinal: number + payload: string +} + +export interface SessionMessageCacheCursor { + key: string + snapshotId: string + messageIds: readonly string[] + beforeIndex: number + startIndex: number + totalCount: number + complete: boolean +} + +export interface SessionMessageCachePage { + messages: unknown[] + startIndex: number + totalCount: number + done: boolean + complete: boolean +} + +interface PreparedSessionMessageCache { + manifest: SessionMessageCacheManifest + records: SessionMessageCacheRecord[] +} + +let databasePromise: Promise | null = null +let clearGeneration = 0 +let cacheEnabled = false +const resetListeners = new Set<() => void>() +let mutationQueue: Promise = Promise.resolve() + +export function isSessionMessageCacheEnabled(): boolean { + return cacheEnabled +} + +export function setSessionMessageCacheEnabled(enabled: boolean): void { + cacheEnabled = enabled + if (!enabled) resetListeners.forEach((listener) => listener()) +} + +export function onSessionMessageCacheReset(listener: () => void): () => void { + resetListeners.add(listener) + return () => resetListeners.delete(listener) +} + +function requestResult(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")) + }) +} + +function transactionDone(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted")) + transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed")) + }) +} + +function openDatabase(): Promise { + if (typeof indexedDB === "undefined") return Promise.resolve(null) + if (databasePromise) return databasePromise + + const opening = new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + request.onupgradeneeded = () => { + const database = request.result + for (const legacy of ["payloads", "metadata"]) { + if (database.objectStoreNames.contains(legacy)) database.deleteObjectStore(legacy) + } + if (!database.objectStoreNames.contains(MANIFEST_STORE)) { + database.createObjectStore(MANIFEST_STORE, { keyPath: "key" }) + } + if (!database.objectStoreNames.contains(MESSAGE_STORE)) { + const store = database.createObjectStore(MESSAGE_STORE, { + keyPath: ["sessionKey", "snapshotId", "messageId"], + }) + store.createIndex(SESSION_INDEX, "sessionKey") + } + } + request.onsuccess = () => { + const database = request.result + database.onversionchange = () => { + database.close() + databasePromise = null + } + resolve(database) + } + request.onerror = () => reject(request.error ?? new Error("Failed to open session message cache")) + }) + databasePromise = opening.catch((error) => { + databasePromise = null + throw error + }) + return databasePromise +} + +function createSnapshotId(): string { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +export function createSessionMessageCacheKey(workspace: string, sessionId: string): string { + return `${workspace.replace(/\\/g, "/").replace(/\/+$/, "")}\u0000${sessionId}` +} + +function messageIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { id?: unknown }; id?: unknown } + const id = candidate.info?.id ?? candidate.id + return typeof id === "string" && id.length > 0 ? id : null +} + +function messageSessionIdOf(value: unknown): string | null { + if (!value || typeof value !== "object") return null + const candidate = value as { info?: { sessionID?: unknown }; sessionId?: unknown } + const id = candidate.info?.sessionID ?? candidate.sessionId + return typeof id === "string" && id.length > 0 ? id : null +} + +function sessionIdFromKey(key: string): string { + return key.slice(key.lastIndexOf("\u0000") + 1) +} + +function isValidManifest(value: unknown, expectedKey: string): value is SessionMessageCacheManifest { + if (!value || typeof value !== "object") return false + const manifest = value as SessionMessageCacheManifest + return manifest.key === expectedKey && + typeof manifest.snapshotId === "string" && manifest.snapshotId.length > 0 && + Array.isArray(manifest.messageIds) && manifest.messageIds.every((id) => typeof id === "string" && id.length > 0) && + new Set(manifest.messageIds).size === manifest.messageIds.length && + Number.isSafeInteger(manifest.startIndex) && manifest.startIndex >= 0 && + Number.isSafeInteger(manifest.totalCount) && manifest.totalCount === manifest.startIndex + manifest.messageIds.length && + manifest.complete === (manifest.startIndex === 0) && + Number.isFinite(manifest.byteSize) && manifest.byteSize >= 0 && + Number.isFinite(manifest.savedAt) +} + +function exceedsJsonByteLimit(value: unknown, limit: number): boolean { + const seen = new WeakSet() + let bytes = 0 + const addString = (text: string) => { + bytes += 4 + for (let index = 0; index < text.length && bytes <= limit; index += 1) { + const code = text.charCodeAt(index) + bytes += code < 0x20 || (code >= 0xd800 && code <= 0xdfff) ? 12 : code === 0x22 || code === 0x5c ? 4 : 2 + } + } + const visit = (current: unknown, depth: number): void => { + if (bytes > limit || depth > 100) { + bytes = limit + 1 + return + } + if (typeof current === "string") addString(current) + else if (typeof current === "number" || typeof current === "bigint") bytes += String(current).length * 2 + else if (typeof current === "boolean") bytes += current ? 8 : 10 + else if (current === null) bytes += 8 + else if (Array.isArray(current)) { + if (seen.has(current)) { bytes = limit + 1; return } + seen.add(current) + bytes += 4 + Math.max(0, current.length - 1) * 2 + for (const item of current) visit(item === undefined ? null : item, depth + 1) + } else if (current && typeof current === "object") { + if (seen.has(current)) { bytes = limit + 1; return } + seen.add(current) + bytes += 4 + let count = 0 + for (const key in current) { + if (!Object.prototype.hasOwnProperty.call(current, key)) continue + const item = (current as Record)[key] + if (item === undefined || typeof item === "function" || typeof item === "symbol") continue + if (count++ > 0) bytes += 2 + addString(key) + bytes += 2 + visit(item, depth + 1) + if (bytes > limit) break + } + } + } + visit(value, 0) + return bytes > limit +} + +export function prepareSessionMessageCache( + key: string, + messages: readonly unknown[], + snapshotId = createSnapshotId(), + byteLimit = MAX_SESSION_MESSAGE_CACHE_BYTES, + savedAt = Date.now(), +): PreparedSessionMessageCache | null { + let byteSize = 0 + let startIndex = messages.length + const records: SessionMessageCacheRecord[] = [] + const seen = new Set() + try { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const messageId = messageIdOf(messages[index]) + if (!messageId || seen.has(messageId)) return null + seen.add(messageId) + const remaining = byteLimit - byteSize + if (exceedsJsonByteLimit(messages[index], remaining)) break + const payload = JSON.stringify(messages[index]) + const payloadBytes = payload.length * 2 + if (payloadBytes > byteLimit || byteSize + payloadBytes > byteLimit) break + byteSize += payloadBytes + startIndex = index + records.push({ + sessionKey: key, + snapshotId, + messageId, + ordinal: index, + payload, + }) + } + } catch { + return null + } + if (messages.length > 0 && startIndex === messages.length) return null + records.reverse() + return { + manifest: { + key, + snapshotId, + messageIds: records.map((record) => record.messageId), + startIndex, + totalCount: messages.length, + complete: startIndex === 0, + byteSize, + savedAt, + }, + records, + } +} + +export function selectSessionMessageCacheEvictions( + entries: readonly SessionMessageCacheManifest[], + byteLimit = MAX_TOTAL_MESSAGE_CACHE_BYTES, + entryLimit = MAX_SESSION_MESSAGE_CACHE_ENTRIES, +): string[] { + const oldestFirst = [...entries].sort((left, right) => left.savedAt - right.savedAt || left.key.localeCompare(right.key)) + let bytes = oldestFirst.reduce((total, entry) => total + entry.byteSize, 0) + let count = oldestFirst.length + const evictions: string[] = [] + for (const entry of oldestFirst) { + if (bytes <= byteLimit && count <= entryLimit) break + evictions.push(entry.key) + bytes -= entry.byteSize + count -= 1 + } + return evictions +} + +function enqueueMutation(operation: () => Promise): Promise { + const result = mutationQueue.then(operation) + mutationQueue = result.then(() => undefined, () => undefined) + return result +} + +async function readSessionRecordKeys(database: IDBDatabase, sessionKeys: readonly string[]): Promise> { + if (sessionKeys.length === 0) return new Map() + const transaction = database.transaction(MESSAGE_STORE, "readonly") + const completion = transactionDone(transaction) + const index = transaction.objectStore(MESSAGE_STORE).index(SESSION_INDEX) + const requests = sessionKeys.map(async (sessionKey) => [ + sessionKey, + await requestResult(index.getAllKeys(IDBKeyRange.only(sessionKey))), + ] as const) + const entries = await Promise.all(requests) + await completion + return new Map(entries) +} + +export async function openSessionMessageCache( + workspace: string, + sessionId: string, +): Promise { + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction(MANIFEST_STORE, "readonly") + const completion = transactionDone(transaction) + const key = createSessionMessageCacheKey(workspace, sessionId) + const manifest = await requestResult(transaction.objectStore(MANIFEST_STORE).get(key)) as unknown + await completion + if (generation !== clearGeneration) return null + if (!manifest) return null + if (!isValidManifest(manifest, key)) { + await deleteCorruptSessionMessageCacheKey(key, manifest) + return null + } + return { + key: manifest.key, + snapshotId: manifest.snapshotId, + messageIds: manifest.messageIds, + beforeIndex: manifest.messageIds.length, + startIndex: manifest.startIndex, + totalCount: manifest.totalCount, + complete: manifest.complete, + } +} + +export async function readSessionMessageCachePage( + cursor: SessionMessageCacheCursor, + limit = DEFAULT_SESSION_MESSAGE_CACHE_PAGE_SIZE, +): Promise<{ page: SessionMessageCachePage; cursor: SessionMessageCacheCursor } | null> { + if (cursor.beforeIndex <= 0) return null + const generation = clearGeneration + const database = await openDatabase() + if (!database || generation !== clearGeneration) return null + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readonly") + const completion = transactionDone(transaction) + const manifestRequest = transaction.objectStore(MANIFEST_STORE).get(cursor.key) + const start = Math.max(0, cursor.beforeIndex - Math.max(1, limit)) + const ids = cursor.messageIds.slice(start, cursor.beforeIndex) + const messageStore = transaction.objectStore(MESSAGE_STORE) + const requests = ids.map((messageId) => requestResult(messageStore.get([cursor.key, cursor.snapshotId, messageId]))) + const manifest = await requestResult(manifestRequest) as unknown + const records = await Promise.all(requests) as Array + await completion + if (generation !== clearGeneration) return null + if (!isValidManifest(manifest, cursor.key) || manifest.snapshotId !== cursor.snapshotId) return null + if (records.some((record) => !record)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + + try { + const messages = records.map((record) => JSON.parse(record!.payload)) + const sessionId = sessionIdFromKey(cursor.key) + if (messages.some((message, index) => messageIdOf(message) !== ids[index] || messageSessionIdOf(message) !== sessionId)) { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } + const nextCursor = { ...cursor, beforeIndex: start } + return { + page: { + messages, + startIndex: cursor.startIndex + start, + totalCount: cursor.totalCount, + done: start === 0, + complete: cursor.complete, + }, + cursor: nextCursor, + } + } catch { + await deleteCorruptSessionMessageCacheKey(cursor.key, manifest) + return null + } +} + +export async function writeSessionMessageCache(workspace: string, sessionId: string, messages: unknown[]): Promise { + const generation = clearGeneration + const key = createSessionMessageCacheKey(workspace, sessionId) + const prepared = prepareSessionMessageCache(key, messages) + if (!prepared) { + await deleteSessionMessageCache(workspace, sessionId) + return false + } + return enqueueMutation(async () => { + const database = await openDatabase() + if (!database || generation !== clearGeneration) return false + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const existing = await requestResult(manifestRead.objectStore(MANIFEST_STORE).getAll()) as unknown[] + await manifestReadDone + const validExisting = existing.filter((manifest): manifest is SessionMessageCacheManifest => { + const manifestKey = (manifest as { key?: unknown })?.key + return typeof manifestKey === "string" && isValidManifest(manifest, manifestKey) + }) + const invalidKeys = existing + .filter((manifest) => !validExisting.includes(manifest as SessionMessageCacheManifest)) + .map((manifest) => (manifest as { key?: unknown })?.key) + .filter((manifestKey): manifestKey is string => typeof manifestKey === "string" && manifestKey !== key) + prepared.manifest.savedAt = Math.max(prepared.manifest.savedAt, ...validExisting.map((manifest) => manifest.savedAt + 1)) + const projected = [...validExisting.filter((manifest) => manifest.key !== key), prepared.manifest] + const evictions = selectSessionMessageCacheEvictions(projected) + const recordKeys = await readSessionRecordKeys(database, [key, ...evictions, ...invalidKeys]) + if (generation !== clearGeneration) return false + + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const manifestStore = transaction.objectStore(MANIFEST_STORE) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + for (const record of prepared.records) messageStore.put(record) + manifestStore.put(prepared.manifest) + for (const evictedKey of evictions) { + for (const recordKey of recordKeys.get(evictedKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(evictedKey) + } + for (const invalidKey of invalidKeys) { + for (const recordKey of recordKeys.get(invalidKey) ?? []) messageStore.delete(recordKey) + manifestStore.delete(invalidKey) + } + await completion + return true + }) +} + +export async function deleteSessionMessageCache(workspace: string, sessionId: string): Promise { + const key = createSessionMessageCacheKey(workspace, sessionId) + await deleteSessionMessageCacheKey(key) +} + +async function deleteSessionMessageCacheKey(key: string): Promise { + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +async function deleteCorruptSessionMessageCacheKey(key: string, observed: unknown): Promise { + const observedSnapshotId = (observed as { snapshotId?: unknown })?.snapshotId + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const manifestRead = database.transaction(MANIFEST_STORE, "readonly") + const manifestReadDone = transactionDone(manifestRead) + const current = await requestResult(manifestRead.objectStore(MANIFEST_STORE).get(key)) as unknown + await manifestReadDone + if (!current) return + if (typeof observedSnapshotId === "string") { + if ((current as { snapshotId?: unknown }).snapshotId !== observedSnapshotId) return + } else if (isValidManifest(current, key)) { + return + } + const recordKeys = await readSessionRecordKeys(database, [key]) + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + const messageStore = transaction.objectStore(MESSAGE_STORE) + for (const recordKey of recordKeys.get(key) ?? []) messageStore.delete(recordKey) + transaction.objectStore(MANIFEST_STORE).delete(key) + await completion + }) +} + +export async function clearSessionMessageCache(): Promise { + clearGeneration += 1 + await enqueueMutation(async () => { + const database = await openDatabase() + if (!database) return + const transaction = database.transaction([MANIFEST_STORE, MESSAGE_STORE], "readwrite") + const completion = transactionDone(transaction) + transaction.objectStore(MANIFEST_STORE).clear() + transaction.objectStore(MESSAGE_STORE).clear() + await completion + }) +} diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index eb788067e..53be8035a 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -166,6 +166,7 @@ class SSEManager { log.warn("Dropping malformed event", event) return } + if (this.shouldHandleEvent && !this.shouldHandleEvent(instanceId)) return log.info("Received event", { type: event.type, event }) @@ -293,6 +294,7 @@ class SSEManager { onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void onWorktreeReady?: (instanceId: string, event: WorktreeReadyEvent) => void | Promise onConnectionLost?: (instanceId: string, reason: string) => void | Promise + shouldHandleEvent?: (instanceId: string) => boolean getStatus(instanceId: string): ConnectionStatus | null { return deriveDisplayConnectionStatus(connectionStatus().get(instanceId) ?? null, transportStatus()) diff --git a/packages/ui/src/stores/client-state.ts b/packages/ui/src/stores/client-state.ts index b13376e95..fb29dca9a 100644 --- a/packages/ui/src/stores/client-state.ts +++ b/packages/ui/src/stores/client-state.ts @@ -2,6 +2,7 @@ import { createSignal } from "solid-js" import { clearNativeClientState, loadNativeClientState, saveNativeClientState, setNativeRestoreEnabled } from "../lib/native/client-state" import { decodeClientSnapshot, isFutureClientSnapshot, normalizeRestorableSession } from "./client-state-codec" import type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } from "./client-state-codec" +import { clearSessionMessageCache, setSessionMessageCacheEnabled } from "../lib/session-message-cache" export type { ClientSnapshotV1, RestorableSessionState, RestorableSidecarTabState, RestorableTabState, RestorableWorkspaceTabState } const SAVE_DEBOUNCE_MS = 250 const FLUSH_MAX_ATTEMPTS = 3 @@ -233,6 +234,8 @@ export async function clearRestoredClientState(): Promise { await runDestructiveTransition(async () => { if (!clientStateIsPrimary()) throw new Error("Client state is not owned by this window") await executeDestructiveTransaction(clearNativeClientState, "Native client state clear was rejected", true) + setSessionMessageCacheEnabled(false) + await clearSessionMessageCache() }) } @@ -244,15 +247,19 @@ export async function setRestorePreviousStateEnabled(enabled: boolean): Promise< if (!await setNativeRestoreEnabled(true)) throw new Error("Native restore preference update was rejected") writeBlock = false setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) return } setRestorePreviousStateEnabledSignal(false) + setSessionMessageCacheEnabled(false) try { await executeDestructiveTransaction(() => setNativeRestoreEnabled(false), "Native restore preference update was rejected") } catch (error) { setRestorePreviousStateEnabledSignal(true) + setSessionMessageCacheEnabled(true) throw error } + await clearSessionMessageCache() }) } @@ -273,10 +280,12 @@ export function initializeClientState(): Promise { writeBlock = isFutureClientSnapshot(loaded.snapshot) ? "snapshot" : false const snapshot = decodeClientSnapshot(loaded.snapshot) resetLoadedState(snapshot, true) + setSessionMessageCacheEnabled(!writeBlock) if (!writeBlock && migrateLegacyLayoutValues()) scheduleSave() } catch (error) { initialized = true setClientStateIsPrimary(false) + setSessionMessageCacheEnabled(false) resetLoadedState() console.warn("[client-state] failed to initialize client state", error) } diff --git a/packages/ui/src/stores/delta-buffer.test.ts b/packages/ui/src/stores/delta-buffer.test.ts index 09072783e..952cbc8e6 100644 --- a/packages/ui/src/stores/delta-buffer.test.ts +++ b/packages/ui/src/stores/delta-buffer.test.ts @@ -6,6 +6,7 @@ import { clearPendingDeltasForPart, enqueueDelta, flushPendingDeltasForMessage, + holdDelta, resetDeltaBufferForTests, setFlushCallback, } from "./delta-buffer.ts" @@ -99,4 +100,22 @@ describe("delta buffer", () => { ], ]) }) + + it("holds an orphan delta until its HTTP baseline is resident", () => { + holdDelta("instance-1", "message-1", "part-1", "text", "late") + let attempts = 0 + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return false + }) + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return true + }) + flushPendingDeltasForMessage("instance-1", "message-1", () => { + attempts += 1 + return true + }) + assert.equal(attempts, 2) + }) }) diff --git a/packages/ui/src/stores/delta-buffer.ts b/packages/ui/src/stores/delta-buffer.ts index 3307257a4..d63cb8ccd 100644 --- a/packages/ui/src/stores/delta-buffer.ts +++ b/packages/ui/src/stores/delta-buffer.ts @@ -9,19 +9,46 @@ const DELTA_FLUSH_INTERVAL = 50 -const pendingDeltas = new Map() +type PendingDelta = { instanceId: string; sessionId?: string; messageId: string; partId: string; field: string; delta: string } +const pendingDeltas = new Map() let deltaFlushTimer: ReturnType | null = null -export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string) { +export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string, sessionId?: string) { const key = `${instanceId}:${messageId}:${partId}:${field}` const existing = pendingDeltas.get(key) const accumulated = existing ? existing.delta + delta : delta - pendingDeltas.set(key, { instanceId, messageId, partId, field, delta: accumulated }) + const resolvedSessionId = sessionId ?? existing?.sessionId + pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: accumulated }) if (deltaFlushTimer === null) { deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL) } } +export function holdDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string, sessionId?: string) { + const key = `${instanceId}:${messageId}:${partId}:${field}` + const existing = pendingDeltas.get(key) + const resolvedSessionId = sessionId ?? existing?.sessionId + pendingDeltas.set(key, { instanceId, ...(resolvedSessionId ? { sessionId: resolvedSessionId } : {}), messageId, partId, field, delta: existing ? existing.delta + delta : delta }) +} + +export function clearPendingDeltasForSession(instanceId: string, sessionId: string): void { + for (const [key, pending] of pendingDeltas) if (pending.instanceId === instanceId && pending.sessionId === sessionId) pendingDeltas.delete(key) +} + +export function clearPendingDeltasForInstance(instanceId: string): void { + for (const [key, pending] of pendingDeltas) if (pending.instanceId === instanceId) pendingDeltas.delete(key) +} + +export function clearPendingDeltasForMessage(instanceId: string, messageId: string): boolean { + let cleared = false + for (const [key, pending] of pendingDeltas) { + if (pending.instanceId !== instanceId || pending.messageId !== messageId) continue + pendingDeltas.delete(key) + cleared = true + } + return cleared +} + export function clearPendingDeltasForPart(instanceId: string, messageId: string, partId: string) { const keysToDelete: string[] = [] for (const key of pendingDeltas.keys()) { @@ -37,7 +64,7 @@ export function clearPendingDeltasForPart(instanceId: string, messageId: string, export function flushPendingDeltasForMessage( instanceId: string, messageId: string, - applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => void + applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => boolean | void ): void { const prefix = `${instanceId}:${messageId}:` const keysToFlush: string[] = [] @@ -49,19 +76,19 @@ export function flushPendingDeltasForMessage( for (const key of keysToFlush) { const pending = pendingDeltas.get(key) if (pending) { - pendingDeltas.delete(key) - applyDelta(instanceId, { + const applied = applyDelta(instanceId, { messageId: pending.messageId, partId: pending.partId, field: pending.field, delta: pending.delta, }) + if (applied !== false) pendingDeltas.delete(key) } } } export function setFlushCallback( - callback: (batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void + callback: (batch: PendingDelta[]) => void ) { // Store callback for flushDeltas to use flushCallback = callback @@ -76,7 +103,7 @@ export function resetDeltaBufferForTests() { flushCallback = null } -let flushCallback: ((batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void) | null = null +let flushCallback: ((batch: PendingDelta[]) => void) | null = null function flushDeltas() { deltaFlushTimer = null diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 04a40f3dc..77d1d50e6 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -36,6 +36,7 @@ import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + purgeInstanceSessionState, sessions, setSessionPendingPermission, setSessionPendingQuestion, @@ -113,6 +114,7 @@ serverEvents.on("yolo.autoAccepted", (event) => { }) const [instances, setInstances] = createSignal>(new Map()) +sseManager.shouldHandleEvent = (instanceId) => instances().has(instanceId) const [activeInstanceId, setActiveInstanceId] = createSignal(null) const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) @@ -679,8 +681,8 @@ async function rehydrateInstance(instanceId: string, options?: { reason?: string return pendingRehydrations.get(instanceId) } + const instance = instances().get(instanceId) const promise = (async () => { - const instance = instances().get(instanceId) if (!instance?.client) { return } @@ -689,8 +691,9 @@ async function rehydrateInstance(instanceId: string, options?: { reason?: string clearReloadableInstanceState(instanceId) await hydrateInstanceData(instanceId, { force: true }) + if (instances().get(instanceId) !== instance) return })().finally(() => { - pendingRehydrations.delete(instanceId) + if (pendingRehydrations.get(instanceId) === promise) pendingRehydrations.delete(instanceId) }) pendingRehydrations.set(instanceId, promise) @@ -702,14 +705,15 @@ async function disposeInstance(instanceId: string): Promise { return pendingDisposeRequests.get(instanceId)! } + const instance = instances().get(instanceId) const promise = (async () => { const ok = await postInstanceDispose(instanceId) - if (ok) { + if (ok && instances().get(instanceId) === instance) { await rehydrateInstance(instanceId, { reason: "disposed" }) } return ok })().finally(() => { - pendingDisposeRequests.delete(instanceId) + if (pendingDisposeRequests.get(instanceId) === promise) pendingDisposeRequests.delete(instanceId) }) pendingDisposeRequests.set(instanceId, promise) @@ -983,6 +987,8 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { initialHydrations.delete(id) initialSessionHydrations.delete(id) initialWorkspaceMetadataHydrations.delete(id) + pendingDisposeRequests.delete(id) + pendingRehydrations.delete(id) settleInstanceReadyWaiters(id, new Error(`Workspace ${id} was removed before it became ready`)) if (activeInstanceId() === id) { @@ -998,6 +1004,7 @@ function removeInstance(id: string, options: { authoritative?: boolean } = {}) { clearInstanceDeletedSessionAuthority(id) clearInstanceSessionExpansionState(id) clearInstanceSessionSelection(id) + purgeInstanceSessionState(id) if (removedInstance && removedOccurrence >= 0 && options.authoritative !== false) { publishInstanceLifecycleAuthority({ type: "removed", diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index 44c3872a1..eab9b834e 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -14,7 +14,7 @@ interface SessionMetadata { parentId?: string | null } -function resolveSessionMetadata(session?: Session | null): SessionMetadata | undefined { +function resolveSessionMetadata(session?: Session | SessionMetadata | null): SessionMetadata | undefined { if (!session) return undefined return { id: session.id, @@ -71,6 +71,38 @@ export function seedSessionMessagesV2( return true } +export function mergeCachedSessionMessagePageV2( + instanceId: string, + session: Session | SessionMetadata, + messages: Message[], + messageInfos: Map, + expectedRevision: number, +): number | null { + if (!session || !Array.isArray(messages)) return null + const store = messageStoreBus.getOrCreate(instanceId) + if (!canHydrateMessages(expectedRevision, store.getSessionRevision(session.id))) return null + const metadata = resolveSessionMetadata(session) + if (!metadata) return null + store.addOrUpdateSession({ + id: metadata.id, + title: metadata.title, + parentId: metadata.parentId ?? null, + revert: (session as Session)?.revert ?? undefined, + }) + store.mergeCachedMessages(metadata.id, messages.map((message) => ({ + id: message.id, + sessionId: message.sessionId, + role: message.type, + status: normalizeStatus(message.status), + createdAt: message.timestamp, + updatedAt: message.timestamp, + parts: message.parts, + isEphemeral: false, + bumpRevision: false, + })), messageInfos.values()) + return store.getSessionRevision(metadata.id) +} + interface MessageInfoOptions { status?: MessageStatus bumpRevision?: boolean @@ -81,17 +113,19 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null return } const store = messageStoreBus.getOrCreate(instanceId) - const timeInfo = (info.time ?? {}) as { created?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; end?: number; completed?: number } const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now() - const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined + const endAt = typeof timeInfo.end === "number" ? timeInfo.end : timeInfo.completed + const status = options?.status ?? "complete" store.upsertMessage({ id: info.id, sessionId: info.sessionID, role: info.role === "user" ? "user" : "assistant", - status: options?.status ?? "complete", + status, createdAt, updatedAt: endAt ?? createdAt, + isEphemeral: status === "sending" || status === "streaming", bumpRevision: Boolean(options?.bumpRevision), }) store.setMessageInfo(info.id, info) @@ -111,12 +145,12 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null | export function applyPartDeltaV2( instanceId: string, input: { messageId: string; partId: string; field: string; delta: string }, -): void { +): boolean { if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { - return + return false } const store = messageStoreBus.getOrCreate(instanceId) - store.applyPartDelta({ + return store.applyPartDelta({ messageId: input.messageId, partId: input.partId, field: input.field, diff --git a/packages/ui/src/stores/message-v2/bus.ts b/packages/ui/src/stores/message-v2/bus.ts index 86900cd9f..f20307fa2 100644 --- a/packages/ui/src/stores/message-v2/bus.ts +++ b/packages/ui/src/stores/message-v2/bus.ts @@ -1,6 +1,6 @@ import { createInstanceMessageStore } from "./instance-store" import type { InstanceMessageStore } from "./instance-store" -import { clearCacheForInstance } from "../../lib/global-cache" +import { clearCacheForInstance, clearCacheForSession } from "../../lib/global-cache" import { getLogger } from "../../lib/logger" import type { ScrollSnapshot } from "./types" @@ -16,6 +16,7 @@ class MessageStoreBus { private stores = new Map() private teardownHandlers = new Set<(instanceId: string) => void>() private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>() + private sessionChangeHandlers = new Set<(instanceId: string, sessionId: string) => void>() private scrollSnapshotHandlers = new Set< (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void >() @@ -30,6 +31,7 @@ class MessageStoreBus { store ?? createInstanceMessageStore(instanceId, { onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId), + onSessionChanged: (id, sessionId) => this.notifySessionChanged(id, sessionId), onScrollSnapshotChanged: (id, sessionId, scope, snapshot) => this.notifyScrollSnapshotChanged(id, sessionId, scope, snapshot), }) @@ -52,6 +54,7 @@ class MessageStoreBus { } private notifySessionCleared(instanceId: string, sessionId: string) { + clearCacheForSession(instanceId, sessionId) for (const handler of this.sessionClearHandlers) { try { handler(instanceId, sessionId) @@ -61,6 +64,21 @@ class MessageStoreBus { } } + onSessionChanged(handler: (instanceId: string, sessionId: string) => void): () => void { + this.sessionChangeHandlers.add(handler) + return () => this.sessionChangeHandlers.delete(handler) + } + + private notifySessionChanged(instanceId: string, sessionId: string) { + for (const handler of this.sessionChangeHandlers) { + try { + handler(instanceId, sessionId) + } catch (error) { + log.error("Failed to run session change handler", error) + } + } + } + onScrollSnapshotChanged( handler: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void, ): () => void { @@ -111,6 +129,10 @@ class MessageStoreBus { return this.registerInstance(instanceId) } + entries(): IterableIterator<[string, InstanceMessageStore]> { + return this.stores.entries() + } + clearInstanceScrollSnapshots(instanceId: string): void { this.stores.get(instanceId)?.clearScrollSnapshots() this.scrollSnapshotSeeds.delete(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.test.ts b/packages/ui/src/stores/message-v2/instance-store.test.ts index 975b4b9f6..77c04ab10 100644 --- a/packages/ui/src/stores/message-v2/instance-store.test.ts +++ b/packages/ui/src/stores/message-v2/instance-store.test.ts @@ -41,4 +41,57 @@ describe("message-v2 permission state", () => { assert.equal(store.getPermissionState(undefined, "permission-2")?.active, true) }) + it("protects legacy pending permissions that use sessionId", () => { + const store = createInstanceMessageStore("instance-1") + store.addOrUpdateSession({ id: "session-1" }) + store.upsertPermission({ permission: { id: "legacy", sessionId: "session-1", permission: "edit" }, enqueuedAt: 1 }) + assert.equal(store.hasSessionActiveWork("session-1"), true) + }) + +}) + +describe("message-v2 authoritative hydration", () => { + const message = (id: string) => ({ + id, + sessionId: "session-1", + role: "assistant" as const, + status: "complete" as const, + parts: [{ id: `part-${id}`, type: "text", text: id, messageID: id, sessionID: "session-1" }] as any, + }) + const info = (id: string) => ({ id, sessionID: "session-1", role: "assistant", time: { created: 1 } }) as any + + it("replaces stale messages and accepts an authoritative empty session", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1"), message("message-2")], [info("message-1"), info("message-2")]) + + store.hydrateMessages("session-1", [message("message-2")], [info("message-2")]) + assert.equal(store.getMessage("message-1"), undefined) + assert.equal(store.getMessageInfo("message-1"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-2"]) + + store.hydrateMessages("session-1", [], []) + assert.equal(store.getMessage("message-2"), undefined) + assert.deepEqual(store.getSessionMessageIds("session-1"), []) + }) + + it("prepends older cache pages without overwriting live messages", () => { + const store = createInstanceMessageStore("instance-1") + store.mergeCachedMessages("session-1", [message("message-3"), message("message-4")]) + store.upsertMessage({ + ...message("message-4"), + parts: [{ id: "part-message-4", type: "text", text: "live", messageID: "message-4", sessionID: "session-1" }] as any, + }) + store.mergeCachedMessages("session-1", [message("message-1"), message("message-2"), message("message-4")]) + + assert.deepEqual(store.getSessionMessageIds("session-1"), ["message-1", "message-2", "message-3", "message-4"]) + assert.equal((store.getMessage("message-4")?.parts["part-message-4"]?.data as any).text, "live") + }) + + it("bumps authority when a revert anchor is not resident", () => { + const store = createInstanceMessageStore("instance-1") + store.hydrateMessages("session-1", [message("message-1")], [info("message-1")]) + const revision = store.getSessionRevision("session-1") + store.setSessionRevert("session-1", { messageID: "evicted-anchor" }) + assert.ok(store.getSessionRevision("session-1") > revision) + }) }) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 437bea3d4..837a18233 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -11,9 +11,13 @@ import { setPromptDisplayOverride, } from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" -import { mergePermissionRequest } from "../../types/permission" +import { getPermissionSessionId, mergePermissionRequest } from "../../types/permission" +import { getQuestionSessionId } from "../../types/question" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" +import { estimateRetainedBytes } from "../../lib/session-memory-budget" import { mergePendingRequestEntry, shouldSkipPendingRequestUpsert } from "./pending-request-dedupe" + +const DERIVED_RENDER_MEMORY_MULTIPLIER = 3 import type { InstanceMessageState, LatestTodoSnapshot, @@ -35,6 +39,7 @@ const storeLog = getLogger("session") interface MessageStoreHooks { onSessionCleared?: (instanceId: string, sessionId: string) => void + onSessionChanged?: (instanceId: string, sessionId: string) => void onScrollSnapshotChanged?: (instanceId: string, sessionId: string, scope: string, snapshot: ScrollSnapshot) => void } @@ -216,6 +221,7 @@ export interface InstanceMessageStore { setState: SetStoreFunction addOrUpdateSession: (input: SessionUpsertInput) => void hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void + mergeCachedMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) => void upsertMessage: (input: MessageUpsertInput) => void applyPartUpdate: (input: PartUpdateInput) => void applyPartDelta: (input: { @@ -225,7 +231,7 @@ export interface InstanceMessageStore { delta: string bumpRevision?: boolean bumpSessionRevision: boolean - }) => void + }) => boolean removeMessage: (messageId: string, fallbackSessionId?: string) => void removeMessagePart: (messageId: string, partId: string, fallbackSessionId?: string) => void bufferPendingPart: (entry: PendingPartEntry) => void @@ -248,6 +254,10 @@ export interface InstanceMessageStore { getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined getSessionRevision: (sessionId: string) => number getSessionMessageIds: (sessionId: string) => string[] + getResidentSessionIds: () => string[] + getSessionApproximateByteSize: (sessionId: string) => number + hasSessionActiveWork: (sessionId: string) => boolean + hasSessionPendingInput: (sessionId: string) => boolean getLastAssistantMessageId: (sessionId: string) => string | undefined // Index of the most recent message in the session that contains a compaction part. // Returns -1 if there has been no compaction. @@ -347,12 +357,46 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function bumpSessionRevision(sessionId: string) { if (!sessionId) return setState("sessionRevisions", sessionId, (value = 0) => value + 1) + hooks?.onSessionChanged?.(instanceId, sessionId) } function getSessionRevisionValue(sessionId: string) { return state.sessionRevisions[sessionId] ?? 0 } + function getResidentSessionIds() { + return Object.values(state.sessions) + .filter((session) => session.messageIds.length > 0) + .map((session) => session.id) + } + + function getSessionApproximateByteSize(sessionId: string) { + const session = state.sessions[sessionId] + if (!session) return 0 + let bytes = estimateRetainedBytes(session) + estimateRetainedBytes(state.usage[sessionId]) + for (const messageId of session.messageIds) { + bytes += estimateRetainedBytes(state.messages[messageId]) + bytes += estimateRetainedBytes(messageInfoCache.get(messageId)) + } + // ponytail: account conservatively for parsed Markdown/diff caches without walking every renderer cache. + return bytes * DERIVED_RENDER_MEMORY_MULTIPLIER + } + + function hasSessionActiveWork(sessionId: string) { + const session = state.sessions[sessionId] + if (!session) return false + if (session.messageIds.some((messageId) => { + const status = state.messages[messageId]?.status + return status === "sending" || status === "streaming" + })) return true + return hasSessionPendingInput(sessionId) + } + + function hasSessionPendingInput(sessionId: string) { + return state.permissions.queue.some((entry) => getPermissionSessionId(entry.permission) === sessionId) || + state.questions.queue.some((entry) => getQuestionSessionId(entry.request) === sessionId) + } + function getLastAssistantMessageIdValue(sessionId: string) { return state.lastAssistantMessageIds[sessionId] } @@ -432,11 +476,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } function hydrateMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { - if (!Array.isArray(inputs) || inputs.length === 0) return + if (!Array.isArray(inputs)) return ensureSessionEntry(sessionId) const incomingIds = inputs.map((item) => item.id) + const incomingIdSet = new Set(incomingIds) + const staleIds = Object.values(state.messages) + .filter((record) => record.sessionId === sessionId && !incomingIdSet.has(record.id)) + .map((record) => record.id) const normalizedRecords: Record = {} const now = Date.now() @@ -471,6 +519,22 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const nextPermissionsByMessage: Record> = { ...state.permissions.byMessage, } + const nextQuestionsByMessage: Record> = { + ...state.questions.byMessage, + } + + if (staleIds.length > 0) { + clearRecordDisplayCacheForMessages(instanceId, staleIds) + for (const id of staleIds) { + delete nextMessages[id] + delete nextMessageInfoVersion[id] + delete nextPendingParts[id] + delete nextPermissionsByMessage[id] + delete nextQuestionsByMessage[id] + messageInfoCache.delete(id) + clearPromptDisplayOverride(instanceId, sessionId, id) + } + } Object.entries(normalizedRecords).forEach(([id, record]) => { nextMessages[id] = record @@ -486,10 +550,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } batch(() => { - setState("messages", () => nextMessages) - setState("messageInfoVersion", () => nextMessageInfoVersion) - setState("pendingParts", () => nextPendingParts) - setState("permissions", "byMessage", () => nextPermissionsByMessage) + setState("messages", reconcile(nextMessages)) + setState("messageInfoVersion", reconcile(nextMessageInfoVersion)) + setState("pendingParts", reconcile(nextPendingParts)) + setState("permissions", "byMessage", reconcile(nextPermissionsByMessage)) + setState("questions", "byMessage", reconcile(nextQuestionsByMessage)) if (usageState) { setState("usage", sessionId, usageState) @@ -502,6 +567,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt })) recomputeLastAssistantMessageId(sessionId, incomingIds) + clearLatestTodoSnapshot(sessionId) Object.values(normalizedRecords).forEach((record) => { maybeUpdateLatestTodoFromRecord(record) }) @@ -510,6 +576,61 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } + function mergeCachedMessages(sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable) { + if (!Array.isArray(inputs) || inputs.length === 0) return + ensureSessionEntry(sessionId) + const existingIds = new Set(state.sessions[sessionId]?.messageIds ?? []) + const additions = inputs.filter((input) => !existingIds.has(input.id) && !state.messages[input.id]) + if (additions.length === 0) return + + const infoById = new Map() + if (infos) { + for (const info of infos) { + if (typeof info.id === "string") infoById.set(info.id, info) + } + } + const additionIds = additions.map((input) => input.id) + const now = Date.now() + const normalizedRecords: Record = {} + for (const input of additions) { + const normalizedParts = normalizeParts(input.id, input.parts) + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input) + normalizedRecords[input.id] = { + id: input.id, + sessionId: input.sessionId, + role: input.role, + status: input.status, + createdAt: input.createdAt ?? now, + updatedAt: input.updatedAt ?? now, + isEphemeral: input.isEphemeral ?? false, + clientPromptDisplayMetadata, + revision: 0, + partIds: normalizedParts?.ids ?? [], + parts: normalizedParts?.map ?? {}, + } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) + } + + batch(() => { + setState("messages", (current) => ({ ...current, ...normalizedRecords })) + for (const messageId of additionIds) { + const info = infoById.get(messageId) + if (!info) continue + messageInfoCache.set(messageId, info) + setState("messageInfoVersion", messageId, (version = 0) => version + 1) + updateUsageWithInfo(info) + } + setState("sessions", sessionId, (session) => ({ + ...session, + messageIds: [...additionIds, ...(session?.messageIds ?? [])], + updatedAt: Date.now(), + })) + recomputeLastAssistantMessageId(sessionId) + Object.values(normalizedRecords).forEach((record) => maybeUpdateLatestTodoFromRecord(record)) + bumpSessionRevision(sessionId) + }) + } + function insertMessageIntoSession(sessionId: string, messageId: string) { ensureSessionEntry(sessionId) setState("sessions", sessionId, "messageIds", (ids = []) => { @@ -695,13 +816,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt bumpSessionRevision?: boolean }) { if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") { - return + return false } const message = state.messages[input.messageId] if (!message) { // Best-effort: drop deltas for unknown messages. - return + return false } let applied = false @@ -730,6 +851,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt if (applied && (input.bumpSessionRevision ?? true)) { bumpSessionRevision(message.sessionId) } + return applied } function removeMessage(messageId: string, fallbackSessionId?: string) { @@ -1010,9 +1132,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt draft.active = draft.queue[0] ?? null }), ) + const sessionId = getPermissionSessionId(entry.permission) + if (sessionId) bumpSessionRevision(sessionId) } function removePermission(permissionId: string) { + const sessionId = getPermissionSessionId(state.permissions.queue.find((item) => item.permission.id === permissionId)?.permission) setState( "permissions", produce((draft) => { @@ -1033,6 +1158,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) }), ) + if (sessionId) bumpSessionRevision(sessionId) } function getPermissionState(messageId?: string, partId?: string) { @@ -1098,9 +1224,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } }), ) + const sessionId = getQuestionSessionId(entry.request) + if (sessionId) bumpSessionRevision(sessionId) } function removeQuestion(requestId: string) { + const sessionId = getQuestionSessionId(state.questions.queue.find((item) => item.request.id === requestId)?.request) setState( "questions", produce((draft) => { @@ -1121,6 +1250,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) }), ) + if (sessionId) bumpSessionRevision(sessionId) } function getQuestionState(messageId?: string, partId?: string) { @@ -1132,14 +1262,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return { entry, active } } - function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) { + function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string): boolean { const session = state.sessions[sessionId] - if (!session) return + if (!session) return false const stopIndex = session.messageIds.indexOf(revertMessageId) - if (stopIndex === -1) return + if (stopIndex === -1) return false const removedIds = session.messageIds.slice(stopIndex) const keptIds = session.messageIds.slice(0, stopIndex) - if (removedIds.length === 0) return + if (removedIds.length === 0) return false removedIds.forEach((messageId) => clearPromptDisplayOverride(instanceId, sessionId, messageId)) @@ -1189,15 +1319,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt recomputeLastAssistantMessageId(sessionId, keptIds) bumpSessionRevision(sessionId) + return true } function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) { if (!sessionId) return ensureSessionEntry(sessionId) - if (revert?.messageID) { - pruneMessagesAfterRevert(sessionId, revert.messageID) - } + const pruned = revert?.messageID ? pruneMessagesAfterRevert(sessionId, revert.messageID) : false setState("sessions", sessionId, "revert", revert ?? null) + if (!pruned) bumpSessionRevision(sessionId) } function getSessionRevert(sessionId: string) { @@ -1344,6 +1474,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState, addOrUpdateSession, hydrateMessages, + mergeCachedMessages, upsertMessage, applyPartUpdate, applyPartDelta, @@ -1370,6 +1501,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt getScrollSnapshot, getSessionRevision: getSessionRevisionValue, getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], + getResidentSessionIds, + getSessionApproximateByteSize, + hasSessionActiveWork, + hasSessionPendingInput, getLastAssistantMessageId: getLastAssistantMessageIdValue, getLastCompactionMessageIndex, getMessage: (messageId: string) => state.messages[messageId], diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 6c0db47c7..ee893c6f7 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -12,6 +12,7 @@ import { removeMessagePartV2, removeMessageV2 } from "./message-v2/bridge" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { clearConversationPlaybackForSession } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache } from "./session-message-cache" const log = getLogger("actions") @@ -98,6 +99,7 @@ async function sendMessage( if (!session) { throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const messageId = createId("msg") const textPartId = createId("prt") @@ -463,8 +465,10 @@ async function deleteMessagePart(instanceId: string, sessionId: string, messageI }), "part.delete", ) + if (instances().get(instanceId) !== instance) return // Optimistic removal; SSE will also broadcast a part-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessagePartV2(instanceId, messageId, partId) updateSessionInfo(instanceId, sessionId) } @@ -487,8 +491,10 @@ async function deleteMessage(instanceId: string, sessionId: string, messageId: s }), "session.message.delete", ) + if (instances().get(instanceId) !== instance) return // Optimistic removal; SSE will also broadcast a message-removed event. + invalidateSessionMessageCache(instanceId, sessionId) removeMessageV2(instanceId, messageId) updateSessionInfo(instanceId, sessionId) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 28b84ce64..990752cd6 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -6,7 +6,7 @@ import { type Session, type SessionStatus, } from "../types/session" -import type { Message } from "../types/message" +import type { Message, MessageInfo } from "../types/message" import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client" import { instances, reconcilePendingSessionIndicators } from "./instances" @@ -22,7 +22,6 @@ import { cancelSessionGenerationAdmissions, markSessionDeletedAuthoritative, getAuthoritativelyDeletedSessionIdsForInstance, - getDescendantSessions, isBlankSession, messagesLoaded, getSessionMessagesLoadError, @@ -57,7 +56,8 @@ import { deleteSessionAttachments } from "./attachments" import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { mergeCachedSessionMessagePageV2, seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" +import { clearPendingDeltasForMessage, clearPendingDeltasForSession } from "./delta-buffer" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -84,13 +84,44 @@ import { isProjectSessionListComplete, } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" +import { + cacheAuthoritativeSessionMessages, + cancelCachedSessionMessageRestore, + clearCachedSessionMessageShift, + invalidateSessionMessageCache, + restoreCachedSessionMessagePages, +} from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" const log = getLogger("api") const sessionListRequestIds = new Map() let nextSessionListRequestId = 0 const pendingMetadataHydrations = new Map>() const sessionWorkspaceHints = new Map>() -messageStoreBus.onInstanceDestroyed((instanceId) => sessionWorkspaceHints.delete(instanceId)) +messageStoreBus.onInstanceDestroyed((instanceId) => { + sessionWorkspaceHints.delete(instanceId) + const prefix = `${instanceId}:` + for (const key of pendingMetadataHydrations.keys()) if (key.startsWith(prefix)) pendingMetadataHydrations.delete(key) +}) + +function adaptApiMessages(sessionId: string, apiMessages: any[]): { messages: Message[]; infos: Map } { + const infos = new Map() + const messages = apiMessages.map((apiMessage: any) => { + const info = (apiMessage.info || apiMessage) as MessageInfo + const messageId = info.id || String(Date.now()) + infos.set(messageId, info) + return { + id: messageId, + sessionId, + type: info.role === "user" ? "user" as const : "assistant" as const, + parts: (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)), + timestamp: info.time?.created || Date.now(), + status: (info as any).error ? "error" as const : "complete" as const, + version: 0, + } + }) + return { messages, infos } +} function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId @@ -265,6 +296,7 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = const key = `${instanceId}:${sessionId}` const current = pendingMetadataHydrations.get(key) if (current) return current + const instance = instances().get(instanceId) const hydration = (async () => { const candidates = await getSessionWorkspaceCandidates(instanceId, sessionId) let lastError: unknown @@ -272,7 +304,8 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)) for (const candidate of candidates) { try { - await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate) + await hydrateSessionMetadataWithClient(client, instanceId, sessionId, candidate, () => instances().get(instanceId) === instance) + if (instances().get(instanceId) !== instance) return rememberSessionWorkspace(instanceId, sessionId, candidate.workspace) return } catch (error) { @@ -281,7 +314,9 @@ function hydrateSessionMetadata(instanceId: string, sessionId: string, client = } } throw lastError - })().finally(() => pendingMetadataHydrations.delete(key)) + })().finally(() => { + if (pendingMetadataHydrations.get(key) === hydration) pendingMetadataHydrations.delete(key) + }) pendingMetadataHydrations.set(key, hydration) return hydration } @@ -433,11 +468,11 @@ async function fetchSessions(instanceId: string, options?: { reset?: boolean }): let status: SessionStatus let retry = existingSession?.retry ?? null - if (existingStatus === "compacting") { + if (existingStatus === "compacting" && !statusResponseKnown) { status = "compacting" retry = null } else { - status = hasType ? mapSdkSessionStatus(rawStatus) : existingStatus ?? "idle" + status = hasType ? mapSdkSessionStatus(rawStatus) : statusResponseKnown ? "idle" : existingStatus ?? "idle" retry = hasType ? mapSdkSessionRetry(rawStatus) : retry } sessionMap.set(apiSession.id, { @@ -666,6 +701,7 @@ async function createSession(instanceId: string, agent?: string): Promise 0 ? primaryAgents[0].name : "") const defaultModel = await getDefaultModel(instanceId, selectedAgent) + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") if (selectedAgent && isModelValid(instanceId, defaultModel)) { await setAgentModelPreference(instanceId, selectedAgent, defaultModel) @@ -680,6 +716,7 @@ async function createSession(instanceId: string, agent?: string): Promise { - const next = { ...prev } - next.creatingSession.set(instanceId, false) - return next - }) + if (instances().get(instanceId) === instance) { + setLoading((prev) => { + const next = { ...prev } + next.creatingSession.set(instanceId, false) + return next + }) + } } } @@ -789,12 +828,14 @@ async function forkSession( ...(await getSessionWorkspacePayload(instanceId, sourceSessionId)), messageID: options?.messageId, } + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") log.info(`[HTTP] POST /session.fork for instance ${instanceId}`, request) const info = await requestData( client.session.fork(request), "session.fork", ) + if (instances().get(instanceId) !== instance) throw new Error("Instance no longer active") const forkedSession = { id: info.id, instanceId, @@ -882,6 +923,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise { - const next = { ...prev } - const deleting = next.deletingSession.get(instanceId) - if (deleting) { - deleting.delete(sessionId) - } - return next - }) + if (instances().get(instanceId) === instance) { + setLoading((prev) => { + const next = { ...prev } + const deleting = next.deletingSession.get(instanceId) + if (deleting) deleting.delete(sessionId) + return next + }) + } } } function removeSessionRuntimeState(instanceId: string, sessionId: string): void { + clearPendingDeltasForSession(instanceId, sessionId) + invalidateSessionMessageCache(instanceId, sessionId) sessionWorkspaceHints.get(instanceId)?.delete(sessionId) cancelSessionGenerationAdmissions(instanceId, sessionId) markSessionDeletedAuthoritative(instanceId, sessionId) @@ -970,6 +1014,7 @@ async function fetchAgents(instanceId: string): Promise { try { log.info(`[HTTP] GET /app.agents for instance ${instanceId}`) const response = await rootClient.app.agents() + if (instances().get(instanceId) !== instance) return const agentList = (response.data ?? []).map((agent) => ({ name: agent.name, description: agent.description || "", @@ -1004,6 +1049,7 @@ async function fetchProviders(instanceId: string): Promise { try { log.info(`[HTTP] GET /config.providers for instance ${instanceId}`) const response = await rootClient.config.providers() + if (instances().get(instanceId) !== instance) return if (!response.data) return const providerList = response.data.providers.map((provider) => ({ @@ -1033,11 +1079,9 @@ async function fetchProviders(instanceId: string): Promise { async function loadMessages( instanceId: string, sessionId: string, - options?: { force?: boolean; skipChildren?: boolean }, + options?: { force?: boolean }, ): Promise { const force = options?.force ?? false - const skipChildren = options?.skipChildren ?? false - if (force) { setMessagesLoaded((prev) => { const next = new Map(prev) @@ -1077,9 +1121,12 @@ async function loadMessages( throw new Error("Session not found") } + cancelCachedSessionMessageRestore(instanceId, sessionId) const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - const messageRevision = messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) + const store = messageStoreBus.getOrCreate(instanceId) + let expectedRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false + const sessionForV2 = session setLoading((prev) => { const next = { ...prev } @@ -1092,10 +1139,66 @@ async function loadMessages( try { log.info(`[HTTP] GET /session.${"messages"} for instance ${instanceId}`, { sessionId }) - const apiMessages = await requestData( - client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), - "session.messages", + const apiMessagesRequest = getSessionWorkspacePayload(instanceId, sessionId).then((workspacePayload) => + requestData(client.session.messages({ sessionID: sessionId, ...workspacePayload }), "session.messages"), + ) + const apiOutcome = apiMessagesRequest.then( + (messages) => ({ ok: true as const, messages }), + (error) => ({ ok: false as const, error }), ) + let outcome: Awaited | undefined + + if (!force && restorePreviousStateEnabled() && store.getSessionMessageIds(sessionId).length === 0) { + const pages = restoreCachedSessionMessagePages(instanceId, sessionId) + const iterator = pages[Symbol.asyncIterator]() + try { + while (!outcome) { + const winner = await Promise.race([ + apiOutcome.then((value) => ({ kind: "http" as const, value })), + iterator.next().then((value) => ({ kind: "cache" as const, value })), + ]) + if (winner.kind === "http") { + outcome = winner.value + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + void iterator.return?.(undefined).catch(() => undefined) + break + } + if (winner.value.done) { + outcome = await apiOutcome + break + } + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + return + } + const cached = adaptApiMessages(sessionId, winner.value.value.messages) + const revision = mergeCachedSessionMessagePageV2( + instanceId, + sessionForV2, + cached.messages, + cached.infos, + expectedRevision, + ) + if (revision === null) { + cancelCachedSessionMessageRestore(instanceId, sessionId) + void iterator.return?.(undefined).catch(() => undefined) + outcome = await apiOutcome + break + } + expectedRevision = revision + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + } + } catch (error) { + log.warn("Failed to restore cached session messages", { instanceId, sessionId, error }) + invalidateSessionMessageCache(instanceId, sessionId) + } + } + + outcome ??= await apiOutcome + cancelCachedSessionMessageRestore(instanceId, sessionId, { preserveShift: true }) + if (!outcome.ok) throw outcome.error + const apiMessages = outcome.messages if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return @@ -1105,41 +1208,9 @@ async function loadMessages( setSessionMessagesLoadError(instanceId, sessionId, null) - if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - } - } else { - const messagesInfo = new Map() - const messages: Message[] = apiMessages.map((apiMessage: any) => { - const info = apiMessage.info || apiMessage - const role = info.role || "assistant" - const messageId = info.id || String(Date.now()) - - messagesInfo.set(messageId, info) - - const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part)) + const adapted = adaptApiMessages(sessionId, apiMessages) - const message: Message = { - id: messageId, - sessionId, - type: role === "user" ? "user" : "assistant", - parts, - timestamp: info.time?.created || Date.now(), - status: "complete" as const, - version: 0, - } - - return message - }) + if (apiMessages.length > 0) { let agentName = "" let providerID = "" @@ -1181,22 +1252,29 @@ async function loadMessages( return next }) - const sessionForV2 = sessions().get(instanceId)?.get(sessionId) ?? { - id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, + } + + const latestSession = sessions().get(instanceId)?.get(sessionId) ?? sessionForV2 + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return + if (!seedSessionMessagesV2(instanceId, latestSession, adapted.messages, adapted.infos, expectedRevision)) { + retryAfterRevisionConflict = true + } else { + for (const message of adapted.messages) { + retryAfterRevisionConflict ||= clearPendingDeltasForMessage(instanceId, message.id) } - if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) return - if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, messageRevision)) { - retryAfterRevisionConflict = true - } else { - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - reconcilePendingPermissionsV2(instanceId, sessionId) - reconcilePendingQuestionsV2(instanceId, sessionId) + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) + reconcilePendingPermissionsV2(instanceId, sessionId) + reconcilePendingQuestionsV2(instanceId, sessionId) + if (restorePreviousStateEnabled() && !retryAfterRevisionConflict) { + void cacheAuthoritativeSessionMessages(instanceId, sessionId, store.getSessionRevision(sessionId)).catch((error) => + log.warn("Failed to persist authoritative session messages", { instanceId, sessionId, error }), + ) } } @@ -1209,6 +1287,13 @@ async function loadMessages( } throw error } finally { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { + const clearShift = () => { + if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) clearCachedSessionMessageShift(instanceId, sessionId) + } + if (typeof requestAnimationFrame === "function") requestAnimationFrame(clearShift) + else setTimeout(clearShift, 0) + } if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) { setLoading((prev) => { const next = { ...prev } @@ -1221,24 +1306,13 @@ async function loadMessages( if (retryAfterRevisionConflict && sessions().get(instanceId)?.has(sessionId)) { await new Promise((resolve) => setTimeout(resolve, 50)) - return loadMessages(instanceId, sessionId, { force: true, skipChildren }) + if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return + return loadMessages(instanceId, sessionId, { force: true }) } if (!isCurrentMessageLoad(instanceId, sessionId, loadEpoch) || !sessions().get(instanceId)?.has(sessionId)) return updateSessionInfo(instanceId, sessionId) - if (!skipChildren && session.parentId === null) { - for (const child of getDescendantSessions(instanceId, sessionId)) { - void loadMessages(instanceId, child.id, { skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId: sessionId, - error, - }), - ) - } - } } export { diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 427579d04..17da93517 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -20,8 +20,11 @@ import type { EventSessionDeleted } from "../lib/sse-manager" import { requestData } from "../lib/opencode-api" import { enqueueDelta, + clearPendingDeltasForInstance, clearPendingDeltasForPart, + clearPendingDeltasForSession, flushPendingDeltasForMessage, + holdDelta, setFlushCallback, } from "./delta-buffer" import { @@ -63,7 +66,7 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, messagesLoaded, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" @@ -91,11 +94,22 @@ import { import { messageStoreBus } from "./message-v2/bus" import type { InstanceMessageStore } from "./message-v2/instance-store" import { handleConversationAssistantPartUpdated } from "./conversation-speech" +import { cancelCachedSessionMessageRestore, invalidateSessionMessageCache, scheduleSessionMessageCacheWrite } from "./session-message-cache" +import { restorePreviousStateEnabled } from "./client-state" +import { scheduleSessionMemorySweep } from "./session-memory" const log = getLogger("sse") const pendingSessionFetches = new Map>() +const pendingSessionStatuses = new Map() let activeRetryToast: ToastHandle | null = null +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}:` + for (const key of pendingSessionFetches.keys()) if (key.startsWith(prefix)) pendingSessionFetches.delete(key) + for (const key of pendingSessionStatuses.keys()) if (key.startsWith(prefix)) pendingSessionStatuses.delete(key) + clearPendingDeltasForInstance(instanceId) +}) + function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { if (typeof document === "undefined") return false const pref = preferences() @@ -167,17 +181,20 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory const slug = slugFromDirectory ?? getWorktreeSlugForSession(instanceId, sessionId) const client = getRootClient(instanceId) const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) + if (instances().get(instanceId) !== instance) return null try { const info = await requestData( client.session.get({ sessionID: sessionId, ...(workspace ? { workspace } : {}) }), "session.get", ) + if (instances().get(instanceId) !== instance) return null let rawStatus = (info as any)?.status let fetchedStatusKnown = false try { const statuses = await requestData>(client.session.status(), "session.status") + if (instances().get(instanceId) !== instance) return null rawStatus ??= statuses?.[sessionId] fetchedStatusKnown = true } catch (error) { @@ -249,20 +266,29 @@ function ensureSessionStatus( const existing = sessions().get(instanceId)?.get(sessionId) if (existing) { setSessionStatus(instanceId, sessionId, status, { retry }) + scheduleSessionMemorySweep() return } const key = `${instanceId}:${sessionId}` + pendingSessionStatuses.set(key, { status, retry }) if (pendingSessionFetches.has(key)) return const pending = (async () => { const fetched = await fetchSessionInfo(instanceId, sessionId, directory) if (!fetched) return - setSessionStatus(instanceId, sessionId, status, { retry }) + const latest = pendingSessionStatuses.get(key) ?? { status, retry } + setSessionStatus(instanceId, sessionId, latest.status, { retry: latest.retry, force: true }) + scheduleSessionMemorySweep() })() pendingSessionFetches.set(key, pending) - void pending.finally(() => pendingSessionFetches.delete(key)) + void pending.finally(() => { + if (pendingSessionFetches.get(key) === pending) { + pendingSessionFetches.delete(key) + pendingSessionStatuses.delete(key) + } + }) } type MessageRole = "user" | "assistant" @@ -306,6 +332,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId if (!sessionId || !messageId) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) if (part.type === "compaction") { ensureSessionStatus(instanceId, sessionId, "compacting", (event as any)?.directory) } @@ -347,6 +375,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes clearPendingDeltasForPart(instanceId, messageId, part.id) } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) + if (messagesLoaded().get(instanceId)?.has(sessionId)) scheduleSessionMessageCacheWrite(instanceId, sessionId) handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) if (part.type === "tool") { @@ -363,6 +392,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) // Flush any pending deltas for this message before applying the update. // Deltas are buffered for up to 50ms; if message.updated arrives before @@ -372,15 +403,17 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes // message status/metadata update runs on the complete content. flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2) - const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number; completed?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 ? timeInfo.end - : typeof timeInfo.updated === "number" && timeInfo.updated > 0 - ? timeInfo.updated - : typeof timeInfo.created === "number" && timeInfo.created > 0 - ? timeInfo.created - : Date.now() + : typeof timeInfo.completed === "number" && timeInfo.completed > 0 + ? timeInfo.completed + : typeof timeInfo.updated === "number" && timeInfo.updated > 0 + ? timeInfo.updated + : typeof timeInfo.created === "number" && timeInfo.created > 0 + ? timeInfo.created + : Date.now() withSession(instanceId, sessionId, (session) => { const currentUpdated = session.time?.updated ?? 0 @@ -392,7 +425,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const role: MessageRole = info.role === "user" ? "user" : "assistant" const hasError = Boolean((info as any).error) - const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0 + const hasEnded = + (typeof timeInfo.end === "number" && timeInfo.end > 0) || + (typeof timeInfo.completed === "number" && timeInfo.completed > 0) const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming" let record = store.getMessage(messageId) @@ -406,7 +441,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes if (!record) { const createdAt = info.time?.created ?? Date.now() - const endAt = (info.time as { end?: number } | undefined)?.end + const endAt = timeInfo.end ?? timeInfo.completed store.upsertMessage({ id: messageId, sessionId, @@ -420,13 +455,22 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, info, { status, bumpRevision: true }) updateSessionInfo(instanceId, sessionId) + if ( + restorePreviousStateEnabled() && + messagesLoaded().get(instanceId)?.has(sessionId) && + (status === "complete" || status === "error") + ) { + scheduleSessionMessageCacheWrite(instanceId, sessionId) + } } } // Delta buffer callback setup setFlushCallback((batch) => { - for (const { instanceId, messageId, partId, field, delta } of batch) { - applyPartDeltaV2(instanceId, { messageId, partId, field, delta }) + for (const { instanceId, sessionId, messageId, partId, field, delta } of batch) { + if (!applyPartDeltaV2(instanceId, { messageId, partId, field, delta })) { + holdDelta(instanceId, messageId, partId, field, delta, sessionId) + } } }) @@ -435,7 +479,12 @@ function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return - enqueueDelta(instanceId, messageID, partID, field, delta) + const sessionId = props.sessionID ?? messageStoreBus.getInstance(instanceId)?.getMessage(messageID)?.sessionId + if (sessionId) { + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionId)) return + cancelCachedSessionMessageRestore(instanceId, sessionId) + } + enqueueDelta(instanceId, messageID, partID, field, delta, sessionId) } function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { @@ -443,10 +492,21 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (!info) return if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return - const instanceSessions = sessions().get(instanceId) ?? new Map() - const existingSession = instanceSessions.get(info.id) + const hasRevert = Object.prototype.hasOwnProperty.call(info, "revert") + const incomingRevert = info.revert ?? null + const previousRevert = existingSession?.revert ?? null + const revertChanged = hasRevert && ( + incomingRevert?.messageID !== previousRevert?.messageID || + incomingRevert?.partID !== previousRevert?.partID || + incomingRevert?.snapshot !== previousRevert?.snapshot || + incomingRevert?.diff !== previousRevert?.diff + ) + if (revertChanged) { + invalidateSessionMessageLoad(instanceId, info.id) + invalidateSessionMessageCache(instanceId, info.id) + } if (!existingSession) { const newSession = { @@ -492,7 +552,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - setSessionRevertV2(instanceId, info.id, info.revert ?? null) + if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) if (!newSession.parentId) { prependSessionListId(instanceId, newSession.id) } @@ -511,14 +571,14 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo retry: existingSession.retry ?? null, metadata: (info as any).metadata ?? existingSession.metadata, time: mergedTime, - revert: info.revert + revert: hasRevert && info.revert ? { messageID: info.revert.messageID, partID: info.revert.partID, snapshot: info.revert.snapshot, diff: info.revert.diff, } - : existingSession.revert, + : hasRevert ? undefined : existingSession.revert, } let updatedInstanceSessions: Map | undefined @@ -533,7 +593,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo }) syncInstanceSessionIndicator(instanceId, updatedInstanceSessions) - setSessionRevertV2(instanceId, info.id, info.revert ?? null) + if (hasRevert) setSessionRevertV2(instanceId, info.id, incomingRevert) } } @@ -541,6 +601,7 @@ function handleSessionDeleted(instanceId: string, event: EventSessionDeleted): v const properties = event.properties const sessionId = properties?.info?.id ?? properties?.sessionID ?? properties?.id if (!sessionId) return + clearPendingDeltasForSession(instanceId, sessionId) log.info(`[SSE] Session deleted: ${sessionId}`) removeSessionRuntimeState(instanceId, sessionId) @@ -558,6 +619,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void { } ensureSessionStatus(instanceId, sessionId, "idle", (event as any)?.directory) + scheduleSessionMemorySweep() log.info(`[SSE] Session idle: ${sessionId}`) } @@ -569,6 +631,7 @@ function handleSessionStatus(instanceId: string, event: EventSessionStatus): voi const status = mapSdkSessionStatus(rawStatus) const retry = mapSdkSessionRetry(rawStatus) ensureSessionStatus(instanceId, sessionId, status, (event as any)?.directory, retry) + scheduleSessionMemorySweep() if (retry) { const remainingSeconds = Math.max(0, Math.round((retry.next - Date.now()) / 1000)) const countdown = @@ -596,6 +659,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted if (!sessionID) return log.info(`[SSE] Session compacted: ${sessionID}`) + invalidateSessionMessageCache(instanceId, sessionID) const existing = sessions().get(instanceId)?.get(sessionID) if (existing) setSessionStatus(instanceId, sessionID, "working", { force: true }) @@ -631,6 +695,7 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void message = error.message } } + if (message.length > 10_000) message = `${message.slice(0, 10_000)}...` showAlertDialog(tGlobal("sessionEvents.sessionError.message", { message }), { title: tGlobal("sessionEvents.sessionError.title"), @@ -641,8 +706,10 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void { const { sessionID, messageID } = event.properties if (!sessionID || !messageID) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message removed from session ${sessionID}`, { messageID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessageV2(instanceId, messageID, sessionID) updateSessionInfo(instanceId, sessionID) } @@ -650,8 +717,10 @@ function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): v function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void { const { sessionID, messageID, partID } = event.properties if (!sessionID || !messageID || !partID) return + if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(sessionID)) return log.info(`[SSE] Message part removed from session ${sessionID}`, { messageID, partID }) + invalidateSessionMessageCache(instanceId, sessionID) removeMessagePartV2(instanceId, messageID, partID, sessionID) updateSessionInfo(instanceId, sessionID) } diff --git a/packages/ui/src/stores/session-memory.test.ts b/packages/ui/src/stores/session-memory.test.ts new file mode 100644 index 000000000..2a4d406d5 --- /dev/null +++ b/packages/ui/src/stores/session-memory.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { messageStoreBus } from "./message-v2/bus.ts" +import { runSessionMemorySweep, setVisibleSessionMemory } from "./session-memory.ts" +import { setSessions } from "./session-state.ts" + +function addMessage(instanceId: string, sessionId: string, status: "complete" | "streaming" = "complete") { + messageStoreBus.getOrCreate(instanceId).upsertMessage({ + id: `${sessionId}-message`, + sessionId, + role: "assistant", + status, + parts: [{ id: `${sessionId}-part`, type: "text", text: sessionId.repeat(100) }] as any, + }) +} + +test("resident message budget evicts globally across five workspaces while preserving visible and streaming sessions", () => { + const instanceIds = Array.from({ length: 5 }, (_, index) => `memory-workspace-${index}`) + try { + for (const instanceId of instanceIds) { + addMessage(instanceId, "parent") + addMessage(instanceId, "subagent") + } + addMessage(instanceIds[0], "streaming", "streaming") + setVisibleSessionMemory(instanceIds[4], "parent", true) + + const protectedBytes = messageStoreBus.getInstance(instanceIds[4])!.getSessionApproximateByteSize("parent") + + messageStoreBus.getInstance(instanceIds[0])!.getSessionApproximateByteSize("streaming") + const evicted = runSessionMemorySweep(protectedBytes) + + assert.equal(evicted.length, 9) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[4])!.getSessionMessageIds("parent"), ["parent-message"]) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("streaming"), ["streaming-message"]) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("parent"), []) + assert.deepEqual(messageStoreBus.getInstance(instanceIds[0])!.getSessionMessageIds("subagent"), []) + } finally { + setVisibleSessionMemory(instanceIds[4], "parent", false) + for (const instanceId of instanceIds) messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("visible session leases keep a child resident until every visible owner releases it", () => { + const instanceId = "memory-visible-leases", sessionId = "child" + try { + addMessage(instanceId, sessionId) + setVisibleSessionMemory(instanceId, sessionId, true) + setVisibleSessionMemory(instanceId, sessionId, true) + setVisibleSessionMemory(instanceId, sessionId, false) + + assert.deepEqual(runSessionMemorySweep(0), []) + setVisibleSessionMemory(instanceId, sessionId, false) + assert.deepEqual(runSessionMemorySweep(0), [`${instanceId}\u0000${sessionId}`]) + } finally { + setVisibleSessionMemory(instanceId, sessionId, false) + messageStoreBus.unregisterInstance(instanceId) + } +}) + +test("authoritative working status protects a resident session", () => { + const instanceId = "memory-working-status", sessionId = "session" + try { + addMessage(instanceId, sessionId) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, { id: sessionId, status: "working" } as any]]))) + assert.deepEqual(runSessionMemorySweep(0), []) + + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, { id: sessionId, status: "idle" } as any]]))) + assert.deepEqual(runSessionMemorySweep(0), [`${instanceId}\u0000${sessionId}`]) + } finally { + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + messageStoreBus.unregisterInstance(instanceId) + } +}) diff --git a/packages/ui/src/stores/session-memory.ts b/packages/ui/src/stores/session-memory.ts new file mode 100644 index 000000000..0f1e952e9 --- /dev/null +++ b/packages/ui/src/stores/session-memory.ts @@ -0,0 +1,107 @@ +import { getLogger } from "../lib/logger" +import { MAX_HOT_SESSION_MESSAGE_BYTES, selectSessionMemoryEvictions, type SessionMemoryEntry } from "../lib/session-memory-budget" +import { messageStoreBus } from "./message-v2/bus" +import { cancelCachedSessionMessageRestore, isRestoringCachedSessionMessages } from "./session-message-cache" +import { isSessionMessagesLoading, sessions } from "./session-state" + +const log = getLogger("session") +const SWEEP_DELAY_MS = 1_000 +const touched = new Map() +const visibleLeases = new Map() +let sequence = 0 +let sweepTimer: ReturnType | undefined + +function sessionKey(instanceId: string, sessionId: string): string { + return `${instanceId}\u0000${sessionId}` +} + +function splitSessionKey(key: string): [string, string] { + const separator = key.indexOf("\u0000") + return [key.slice(0, separator), key.slice(separator + 1)] +} + +function hasProtectedSessionWork( + store: ReturnType, + sessionId: string, + status: string | undefined, +): boolean { + return status === "idle" ? store.hasSessionPendingInput(sessionId) : store.hasSessionActiveWork(sessionId) +} + +export function scheduleSessionMemorySweep(): void { + if (sweepTimer) return + sweepTimer = setTimeout(() => { + sweepTimer = undefined + runSessionMemorySweep() + }, SWEEP_DELAY_MS) +} + +export function setVisibleSessionMemory(instanceId: string, sessionId: string, isVisible: boolean): void { + const key = sessionKey(instanceId, sessionId) + if (isVisible) { + visibleLeases.set(key, (visibleLeases.get(key) ?? 0) + 1) + touched.set(key, ++sequence) + } else { + const leases = visibleLeases.get(key) ?? 0 + if (leases <= 1) visibleLeases.delete(key) + else visibleLeases.set(key, leases - 1) + } + scheduleSessionMemorySweep() +} + +export function evictResidentSessionMessages(instanceId: string, sessionId: string): boolean { + const store = messageStoreBus.getInstance(instanceId) + const status = sessions().get(instanceId)?.get(sessionId)?.status + if ( + !store || + visibleLeases.has(sessionKey(instanceId, sessionId)) || + status === "working" || + status === "compacting" || + hasProtectedSessionWork(store, sessionId, status) + ) return false + cancelCachedSessionMessageRestore(instanceId, sessionId) + store.clearSession(sessionId, { preserveScroll: true }) + log.info("Evicted resident session messages", { instanceId, sessionId }) + return true +} + +export function runSessionMemorySweep(byteLimit = MAX_HOT_SESSION_MESSAGE_BYTES): string[] { + const entries: SessionMemoryEntry[] = [] + for (const [instanceId, store] of messageStoreBus.entries()) { + for (const sessionId of store.getResidentSessionIds()) { + const key = sessionKey(instanceId, sessionId) + const status = sessions().get(instanceId)?.get(sessionId)?.status + entries.push({ + key, + byteSize: store.getSessionApproximateByteSize(sessionId), + lastTouched: touched.get(key) ?? 0, + protected: visibleLeases.has(key) || status === "working" || status === "compacting" || hasProtectedSessionWork(store, sessionId, status) || + isSessionMessagesLoading(instanceId, sessionId) || isRestoringCachedSessionMessages(instanceId, sessionId), + }) + } + } + + const evicted: string[] = [] + for (const key of selectSessionMemoryEvictions(entries, byteLimit)) { + const [instanceId, sessionId] = splitSessionKey(key) + if (evictResidentSessionMessages(instanceId, sessionId)) evicted.push(key) + } + return evicted +} + +messageStoreBus.onSessionChanged((instanceId, sessionId) => { + touched.set(sessionKey(instanceId, sessionId), ++sequence) + scheduleSessionMemorySweep() +}) + +messageStoreBus.onSessionCleared((instanceId, sessionId) => { + const key = sessionKey(instanceId, sessionId) + touched.delete(key) + visibleLeases.delete(key) +}) + +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}\u0000` + for (const key of touched.keys()) if (key.startsWith(prefix)) touched.delete(key) + for (const key of visibleLeases.keys()) if (key.startsWith(prefix)) visibleLeases.delete(key) +}) diff --git a/packages/ui/src/stores/session-message-cache.ts b/packages/ui/src/stores/session-message-cache.ts new file mode 100644 index 000000000..a7661e899 --- /dev/null +++ b/packages/ui/src/stores/session-message-cache.ts @@ -0,0 +1,364 @@ +import { + deleteSessionMessageCache, + createSessionMessageCacheKey, + type SessionMessageCachePage, + isSessionMessageCacheEnabled, + onSessionMessageCacheReset, + openSessionMessageCache, + readSessionMessageCachePage, + writeSessionMessageCache, + MAX_SESSION_MESSAGE_CACHE_BYTES, +} from "../lib/session-message-cache" +import { createSignal } from "solid-js" +import { getLogger } from "../lib/logger" +import { instances } from "./instances" +import { messageStoreBus } from "./message-v2/bus" +import { estimateRetainedBytes } from "../lib/session-memory-budget" + +const log = getLogger("session") +const WRITE_DEBOUNCE_MS = 500 +const pendingWrites = new Map>() +const cacheGenerations = new Map() +const pendingInvalidations = new Map>() +const invalidatedEntries = new Set() +const invalidationVersions = new Map() +const pendingEntryInvalidations = new Map() +const activeRestores = new Map() +type CacheWriteRequest = { + instanceId: string + sessionId: string + expectedRevision?: number + allowEmpty: boolean + generation: number + reset: number + instanceToken: unknown + workspace: string + entryKey: string + invalidationVersion: number + done: Array<() => void> +} +const queuedWrites = new Map() +let writeWorkerRunning = false +const [restoringSessions, setRestoringSessions] = createSignal>(new Set()) +let resetGeneration = 0 + +function pendingKey(instanceId: string, sessionId: string): string { + return `${instanceId}:${sessionId}` +} + +function cacheGeneration(key: string): number { + return cacheGenerations.get(key) ?? 0 +} + +function setRestoreActive(key: string, active: boolean): void { + setRestoringSessions((current) => { + const next = new Set(current) + if (active) next.add(key) + else next.delete(key) + return next + }) +} + +export function isRestoringCachedSessionMessages(instanceId: string, sessionId: string): boolean { + return restoringSessions().has(pendingKey(instanceId, sessionId)) +} + +export function cancelCachedSessionMessageRestore( + instanceId: string, + sessionId: string, + options?: { preserveShift?: boolean }, +): void { + const key = pendingKey(instanceId, sessionId) + activeRestores.delete(key) + if (!options?.preserveShift) setRestoreActive(key, false) +} + +export function clearCachedSessionMessageShift(instanceId: string, sessionId: string): void { + setRestoreActive(pendingKey(instanceId, sessionId), false) +} + +function workspaceForInstance(instanceId: string): string | null { + return instances().get(instanceId)?.folder ?? null +} + +function waitForIdle(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === "function") { + requestIdleCallback(() => resolve(), { timeout: 2_000 }) + } else { + setTimeout(resolve, 0) + } + }) +} + +function snapshotSession(instanceId: string, sessionId: string, allowEmpty = false): unknown[] | null { + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + const messageIds = store.getSessionMessageIds(sessionId) + if (messageIds.length === 0 && (!allowEmpty || !store.state.sessions[sessionId])) return null + + const result: unknown[] = [] + let bytes = 0 + for (let index = messageIds.length - 1; index >= 0; index -= 1) { + const messageId = messageIds[index] + const record = store.getMessage(messageId) + const info = store.getMessageInfo(messageId) + if (!record || !info || record.isEphemeral || record.status === "sending" || record.status === "streaming") return null + const remaining = MAX_SESSION_MESSAGE_CACHE_BYTES / 2 - bytes + const recordBytes = estimateRetainedBytes(record, remaining) + if (recordBytes > remaining) break + const infoBytes = estimateRetainedBytes(info, remaining - recordBytes) + const messageBytes = recordBytes + infoBytes + if (messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2 || bytes + messageBytes > MAX_SESSION_MESSAGE_CACHE_BYTES / 2) break + + const parts = record.partIds.map((partId) => record.parts[partId]?.data).filter(Boolean).map((part) => { + const { renderCache: _renderCache, pendingPermission: _pendingPermission, ...cacheable } = part as any + return cacheable + }) + result.unshift({ info, parts }) + bytes += messageBytes + } + return result.length > 0 || messageIds.length === 0 ? result : null +} + +async function drainQueuedWrites(): Promise { + if (writeWorkerRunning) return + writeWorkerRunning = true + try { + while (queuedWrites.size > 0) { + const [key, request] = queuedWrites.entries().next().value as [string, CacheWriteRequest] + queuedWrites.delete(key) + try { + await waitForIdle() + if (cacheGeneration(key) !== request.generation || resetGeneration !== request.reset) continue + if (instances().get(request.instanceId) !== request.instanceToken) continue + if ((invalidationVersions.get(request.entryKey) ?? 0) !== request.invalidationVersion) continue + const store = messageStoreBus.getInstance(request.instanceId) + if (!store) continue + if (request.expectedRevision !== undefined && store.getSessionRevision(request.sessionId) !== request.expectedRevision) continue + const messages = snapshotSession(request.instanceId, request.sessionId, request.allowEmpty) + if (!messages) continue + const written = await writeSessionMessageCache(request.workspace, request.sessionId, messages) + if ( + written && + cacheGeneration(key) === request.generation && + resetGeneration === request.reset && + instances().get(request.instanceId) === request.instanceToken && + (invalidationVersions.get(request.entryKey) ?? 0) === request.invalidationVersion && + pendingEntryInvalidations.get(request.entryKey) !== request.invalidationVersion + ) invalidatedEntries.delete(request.entryKey) + } catch (error) { + log.warn("Failed to cache completed session messages", { instanceId: request.instanceId, sessionId: request.sessionId, error }) + } finally { + request.done.forEach((resolve) => resolve()) + } + } + } finally { + writeWorkerRunning = false + if (queuedWrites.size > 0) void drainQueuedWrites() + } +} + +function enqueueSessionMessageCacheWrite( + instanceId: string, + sessionId: string, + options?: { expectedRevision?: number; allowEmpty?: boolean }, +): Promise { + const key = pendingKey(instanceId, sessionId) + const instanceToken = instances().get(instanceId) + const workspace = instanceToken?.folder + if (!instanceToken || !workspace) return Promise.resolve() + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 + return new Promise((resolve) => { + const current = queuedWrites.get(key) + if (current) { + current.expectedRevision = options?.expectedRevision + current.allowEmpty ||= Boolean(options?.allowEmpty) + current.generation = cacheGeneration(key) + current.reset = resetGeneration + current.instanceToken = instanceToken + current.workspace = workspace + current.entryKey = entryKey + current.invalidationVersion = invalidationVersion + current.done.push(resolve) + } else { + queuedWrites.set(key, { + instanceId, + sessionId, + expectedRevision: options?.expectedRevision, + allowEmpty: Boolean(options?.allowEmpty), + generation: cacheGeneration(key), + reset: resetGeneration, + instanceToken, + workspace, + entryKey, + invalidationVersion, + done: [resolve], + }) + } + void drainQueuedWrites() + }) +} + +export async function* restoreCachedSessionMessagePages( + instanceId: string, + sessionId: string, +): AsyncGenerator { + if (!isSessionMessageCacheEnabled()) return + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + if (invalidatedEntries.has(entryKey)) return + const key = pendingKey(instanceId, sessionId) + const generation = cacheGeneration(key) + const reset = resetGeneration + const token = Symbol(key) + let restoredPage = false + activeRestores.set(key, token) + setRestoreActive(key, true) + try { + await pendingInvalidations.get(key) + let cursor = await openSessionMessageCache(workspace, sessionId) + const scroll = messageStoreBus.getInstance(instanceId)?.getScrollSnapshot(sessionId, "message-stream") + const deferredAnchor = scroll && !scroll.atBottom ? scroll.anchorKey : undefined + let buffered: unknown[] = [] + while ( + cursor && + cursor.beforeIndex > 0 && + activeRestores.get(key) === token && + cacheGeneration(key) === generation && + resetGeneration === reset && + !invalidatedEntries.has(entryKey) + ) { + const result = await readSessionMessageCachePage(cursor) + if (!result) return + if ( + activeRestores.get(key) !== token || + cacheGeneration(key) !== generation || + resetGeneration !== reset || + invalidatedEntries.has(entryKey) + ) return + cursor = result.cursor + const page = result.page + if (deferredAnchor && !page.messages.some((message) => (message as any)?.info?.id === deferredAnchor) && cursor.beforeIndex > 0) { + buffered = [...page.messages, ...buffered] + continue + } + if (buffered.length > 0) { + restoredPage = true + yield { ...page, messages: [...page.messages, ...buffered] } + buffered = [] + } else { + restoredPage = true + yield page + } + } + if (buffered.length > 0 && (!deferredAnchor || cursor?.complete)) { + restoredPage = true + yield { messages: buffered, startIndex: cursor?.startIndex ?? 0, totalCount: cursor?.totalCount ?? buffered.length, done: true, complete: cursor?.complete ?? true } + } + } finally { + if (activeRestores.get(key) === token) { + activeRestores.delete(key) + if (!restoredPage) setRestoreActive(key, false) + } + } +} + +export async function cacheAuthoritativeSessionMessages( + instanceId: string, + sessionId: string, + expectedRevision: number, +): Promise { + if (!isSessionMessageCacheEnabled()) return + await enqueueSessionMessageCacheWrite(instanceId, sessionId, { expectedRevision, allowEmpty: true }) +} + +export function scheduleSessionMessageCacheWrite(instanceId: string, sessionId: string): void { + if (!isSessionMessageCacheEnabled()) return + const key = pendingKey(instanceId, sessionId) + const instanceToken = instances().get(instanceId) + const workspace = instanceToken?.folder + if (!instanceToken || !workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = invalidationVersions.get(entryKey) ?? 0 + const existing = pendingWrites.get(key) + if (existing) clearTimeout(existing) + pendingWrites.set(key, setTimeout(() => { + pendingWrites.delete(key) + if (instances().get(instanceId) !== instanceToken) return + if ((invalidationVersions.get(entryKey) ?? 0) !== invalidationVersion) return + void enqueueSessionMessageCacheWrite(instanceId, sessionId) + }, WRITE_DEBOUNCE_MS)) +} + +export function invalidateSessionMessageCache(instanceId: string, sessionId: string): void { + const key = pendingKey(instanceId, sessionId) + cancelCachedSessionMessageRestore(instanceId, sessionId) + cacheGenerations.set(key, cacheGeneration(key) + 1) + const pending = pendingWrites.get(key) + if (pending) { + clearTimeout(pending) + pendingWrites.delete(key) + } + const queued = queuedWrites.get(key) + if (queued) { + queuedWrites.delete(key) + queued.done.forEach((resolve) => resolve()) + } + const workspace = workspaceForInstance(instanceId) + if (!workspace) return + const entryKey = createSessionMessageCacheKey(workspace, sessionId) + const invalidationVersion = (invalidationVersions.get(entryKey) ?? 0) + 1 + invalidationVersions.set(entryKey, invalidationVersion) + pendingEntryInvalidations.set(entryKey, invalidationVersion) + invalidatedEntries.add(entryKey) + const invalidation = deleteSessionMessageCache(workspace, sessionId) + .then(() => { + if ((invalidationVersions.get(entryKey) ?? 0) === invalidationVersion) invalidatedEntries.delete(entryKey) + }) + .catch((error) => log.warn("Failed to invalidate cached session messages", { instanceId, sessionId, error })) + .finally(() => { + if (pendingInvalidations.get(key) === invalidation) pendingInvalidations.delete(key) + if (pendingEntryInvalidations.get(entryKey) === invalidationVersion) pendingEntryInvalidations.delete(entryKey) + }) + pendingInvalidations.set(key, invalidation) +} + +function resetPendingSessionMessageCacheWork(): void { + resetGeneration += 1 + activeRestores.clear() + setRestoringSessions(new Set()) + for (const timer of pendingWrites.values()) clearTimeout(timer) + pendingWrites.clear() + for (const request of queuedWrites.values()) request.done.forEach((resolve) => resolve()) + queuedWrites.clear() + pendingInvalidations.clear() + invalidatedEntries.clear() + invalidationVersions.clear() + pendingEntryInvalidations.clear() +} + +onSessionMessageCacheReset(resetPendingSessionMessageCacheWork) + +messageStoreBus.onInstanceDestroyed((instanceId) => { + const prefix = `${instanceId}:` + for (const [key, timer] of pendingWrites) { + if (!key.startsWith(prefix)) continue + clearTimeout(timer) + pendingWrites.delete(key) + } + for (const [key, request] of queuedWrites) { + if (!key.startsWith(prefix)) continue + queuedWrites.delete(key) + request.done.forEach((resolve) => resolve()) + } + for (const key of cacheGenerations.keys()) { + if (key.startsWith(prefix)) cacheGenerations.set(key, cacheGeneration(key) + 1) + } + for (const key of activeRestores.keys()) { + if (key.startsWith(prefix)) activeRestores.delete(key) + } + setRestoringSessions((current) => new Set([...current].filter((key) => !key.startsWith(prefix)))) +}) diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index bd4f4ab85..aeb960d6d 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -44,10 +44,12 @@ export async function hydrateSessionMetadataWithClient( instanceId: string, sessionId: string, query?: { workspace?: string }, + isCurrent: () => boolean = () => true, ): Promise { const expectedMetadata = sessions().get(instanceId)?.get(sessionId)?.metadata const latest = await requestData(client.session.get({ sessionID: sessionId, ...query }), "session.get") const metadata = normalizeMetadata(latest?.metadata) + if (!isCurrent()) return metadata withSession(instanceId, sessionId, (session) => { if (session.metadata !== expectedMetadata || !shouldReplaceSessionMetadata(session.metadata)) return false diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 99bfeb78f..8cded2410 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -176,4 +176,42 @@ describe("session request authority", () => { cleanup() } }) + + it("preserves historical assistant error status during hydration", async () => { + const instanceId = "errored-message-load", sessionId = "session" + const { client, cleanup } = setup(instanceId) + ;(client.session as any).messages = async () => ({ + data: [{ ...apiMessage("errored-message", sessionId), info: { ...apiMessage("errored-message", sessionId).info, error: { name: "ProviderError" } } }], + }) + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + + try { + await loadMessages(instanceId, sessionId) + assert.equal(messageStoreBus.getOrCreate(instanceId).getMessage("errored-message")?.status, "error") + } finally { + cleanup() + } + }) + + it("loads subagent messages on demand instead of hydrating an entire family", async () => { + const instanceId = "lazy-subagent-messages", parentId = "parent", childId = "child" + const { client, cleanup } = setup(instanceId) + const calls: string[] = [] + ;(client.session as any).messages = async ({ sessionID }: { sessionID: string }) => { + calls.push(sessionID) + return { data: [apiMessage(`${sessionID}-message`, sessionID)] } + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [parentId, session(instanceId, parentId)], + [childId, session(instanceId, childId, parentId)], + ]))) + + try { + await loadMessages(instanceId, parentId) + assert.deepEqual(calls, [parentId]) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(childId), []) + } finally { + cleanup() + } + }) }) diff --git a/packages/ui/src/stores/session-state-purge.test.ts b/packages/ui/src/stores/session-state-purge.test.ts new file mode 100644 index 000000000..f13ded013 --- /dev/null +++ b/packages/ui/src/stores/session-state-purge.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + agents, + loading, + messagesLoaded, + providers, + purgeInstanceSessionState, + sessionInfoByInstance, + sessions, + setAgents, + setLoading, + setMessagesLoaded, + setProviders, + setSessionInfoByInstance, + setSessions, +} from "./session-state.ts" + +test("purging an instance removes its session metadata without touching other workspaces", () => { + const removed = "purged-instance" + const retained = "retained-instance" + setSessions(new Map([[removed, new Map()], [retained, new Map()]])) + setAgents(new Map([[removed, []], [retained, []]])) + setProviders(new Map([[removed, []], [retained, []]])) + setMessagesLoaded(new Map([[removed, new Set(["session"])], [retained, new Set()]])) + setSessionInfoByInstance(new Map([[removed, new Map()], [retained, new Map()]])) + setLoading((current) => ({ ...current, loadingMessages: new Map([[removed, new Set(["session"])], [retained, new Set()]]) })) + + purgeInstanceSessionState(removed) + + for (const state of [sessions(), agents(), providers(), messagesLoaded(), sessionInfoByInstance(), loading().loadingMessages]) { + assert.equal(state.has(removed), false) + assert.equal(state.has(retained), true) + } + purgeInstanceSessionState(retained) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index e9cf30bd0..f59b12b52 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -1207,6 +1207,52 @@ async function cleanupBlankSessions(instanceId: string, excludeSessionId?: strin } } +function removeInstanceMapEntry(map: Map, instanceId: string): Map { + if (!map.has(instanceId)) return map + const next = new Map(map) + next.delete(instanceId) + return next +} + +function purgeInstanceSessionState(instanceId: string): void { + if (!instanceId) return + const prefix = `${instanceId}:` + batch(() => { + setSessions((prev) => removeInstanceMapEntry(prev, instanceId)) + setActiveSessionId((prev) => removeInstanceMapEntry(prev, instanceId)) + setActiveParentSessionId((prev) => removeInstanceMapEntry(prev, instanceId)) + setAgents((prev) => removeInstanceMapEntry(prev, instanceId)) + setProviders((prev) => removeInstanceMapEntry(prev, instanceId)) + setMessagesLoaded((prev) => removeInstanceMapEntry(prev, instanceId)) + setMessageLoadErrors((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionListErrors((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionInfoByInstance((prev) => removeInstanceMapEntry(prev, instanceId)) + setThreadTotalsByInstance((prev) => removeInstanceMapEntry(prev, instanceId)) + setExpandedSessions((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionPagination((prev) => removeInstanceMapEntry(prev, instanceId)) + setSessionSearch((prev) => removeInstanceMapEntry(prev, instanceId)) + setInstanceIndicatorCounts((prev) => removeInstanceMapEntry(prev, instanceId)) + setLoading((prev) => ({ + fetchingSessions: removeInstanceMapEntry(prev.fetchingSessions, instanceId), + creatingSession: removeInstanceMapEntry(prev.creatingSession, instanceId), + deletingSession: removeInstanceMapEntry(prev.deletingSession, instanceId), + loadingMessages: removeInstanceMapEntry(prev.loadingMessages, instanceId), + })) + setAuthoritativeSessionSelectionInstanceIds((prev) => { + if (!prev.has(instanceId)) return prev + const next = new Set(prev) + next.delete(instanceId) + return next + }) + setSessionDraftPrompts((prev) => new Map([...prev].filter(([key]) => !key.startsWith(prefix)))) + setAuthoritativeDraftKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + setAuthoritativelyDeletedSessionKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + setAuthoritativeSessionExpansionKeys((prev) => new Set([...prev].filter((key) => !key.startsWith(prefix)))) + }) + for (const key of generationAdmissions.keys()) if (key.startsWith(prefix)) generationAdmissions.delete(key) + for (const key of messageLoadEpochs.keys()) if (key.startsWith(prefix)) messageLoadEpochs.delete(key) +} + export { sessions, setSessions, @@ -1291,6 +1337,7 @@ export { getSessionInfo, isBlankSession, cleanupBlankSessions, + purgeInstanceSessionState, SESSION_PAGE_SIZE, sessionPagination, sessionSearch,