diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 01b6e377a..30f08431a 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -236,7 +236,7 @@ jobs: - name: Test changed runnable UI behavior run: >- - node --import tsx --test + node --import tsx --test --test-force-exit packages/ui/src/components/browser-frame-security.test.ts packages/ui/src/components/message-history-pagination.test.ts packages/ui/src/components/message-timeline-v2.test.ts @@ -246,6 +246,8 @@ jobs: packages/ui/src/components/session/session-bottom-pin-intent.test.ts packages/ui/src/components/session/session-idle-attention.test.ts packages/ui/src/components/session-list-visibility.test.ts + packages/ui/src/components/tool-call/permission-block.test.ts + packages/ui/src/components/tool-call/render-memory.test.ts packages/ui/src/components/settings/info-settings-diagnostics.test.ts packages/ui/src/components/tool-call/tool-presentation.test.ts packages/ui/src/components/transcript-visibility.test.ts @@ -255,6 +257,7 @@ jobs: packages/ui/src/components/virtual-reader-settlement.test.ts packages/ui/src/lib/client-identity.test.ts packages/ui/src/lib/filesystem-events.test.ts + packages/ui/src/lib/global-cache.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts @@ -263,9 +266,11 @@ jobs: packages/ui/src/lib/clipboard.test.ts packages/ui/src/lib/message-selection-position.test.ts packages/ui/src/lib/model-visibility.test.ts + packages/ui/src/lib/retained-size.test.ts packages/ui/src/lib/provider-auth.test.ts packages/ui/src/lib/native/browser.test.ts packages/ui/src/lib/runtime-env.test.ts + packages/ui/src/lib/session-transcript-lru.test.ts packages/ui/src/lib/server-meta.test.ts packages/ui/src/lib/theme-scheme.test.ts packages/ui/src/lib/trailing-resync.test.ts @@ -305,6 +310,10 @@ jobs: node --conditions=browser --import tsx --test --test-force-exit packages/ui/src/components/form-request-tool-target.test.ts packages/ui/src/components/form-request.test.ts + packages/ui/src/components/instance/shell/useSessionCache.test.ts + packages/ui/src/components/permission-diff-review.test.ts + packages/ui/src/components/tool-call/renderers/task-copy.test.ts + packages/ui/src/components/tool-call/renderer-copy.test.ts packages/ui/src/components/tool-call/diff-payload.test.ts packages/ui/src/components/tool-call/renderers/edit.test.ts packages/ui/src/components/tool-call/renderers/task-title.test.ts @@ -319,7 +328,10 @@ jobs: packages/ui/src/stores/app-tabs.test.ts packages/ui/src/stores/forms.test.ts packages/ui/src/stores/instances-restore-ownership.test.ts + packages/ui/src/stores/message-v2/bus.test.ts + packages/ui/src/stores/message-v2/transcript-eviction.test.ts packages/ui/src/stores/opencode-data.test.ts + packages/ui/src/stores/opencode-data-idle.test.ts packages/ui/src/stores/opencode-data-settlement.test.ts packages/ui/src/stores/message-v2/empty-content.test.ts packages/ui/src/stores/permission-lifecycle.test.ts diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 1d836b0e5..71dc45c18 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -681,6 +681,7 @@ const InstanceShell2: Component = (props) => { instanceId: () => props.instance.id, instanceSessions: allInstanceSessions, activeSessionId: activeSessionIdForInstance, + isActiveInstance: () => Boolean(props.isActiveInstance), }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) diff --git a/packages/ui/src/components/instance/shell/useSessionCache.test.ts b/packages/ui/src/components/instance/shell/useSessionCache.test.ts new file mode 100644 index 000000000..e023aa107 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useSessionCache.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import { it } from "node:test" +import { createRoot, createSignal } from "solid-js" +import { useSessionCache } from "./useSessionCache.ts" +import { messageStoreBus } from "../../../stores/message-v2/bus.ts" +import { sessions, setSessions, loading, setLoading } from "../../../stores/session-state.ts" +import { SESSION_TRANSCRIPT_BYTE_BUDGET } from "../../../stores/session-transcript-memory.ts" + +it("keeps a visible over-budget transcript pinned across catalogue and loading changes, then releases it on hide", async () => { + const instanceId = "cache-visible-lifetime", sessionId = "selected" + const previousSessions = sessions(), previousLoading = loading() + const session = { id: sessionId, instanceId, title: "Selected", parentId: null, status: "idle" } as any + setSessions(prev => new Map(prev).set(instanceId, new Map([[sessionId, session]]))) + const store = messageStoreBus.getOrCreate(instanceId) + // Exercise the real queue/coordinator without allocating a large payload. + store.estimateSessionRetainedBytes = async () => SESSION_TRANSCRIPT_BYTE_BUDGET + 1 + store.upsertMessage({ id: "message", sessionId, role: "assistant", status: "complete" }) + const [active, setActive] = createSignal(true) + let dispose = () => {} + try { + createRoot(done => { + dispose = done + useSessionCache({ instanceId: () => instanceId, activeSessionId: () => sessionId, + isActiveInstance: active, instanceSessions: () => sessions().get(instanceId)! }) + }) + await new Promise(resolve => setTimeout(resolve, 250)) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["message"]) + setSessions(prev => new Map(prev).set(instanceId, new Map([[sessionId, { ...session, title: "Renamed" }]]))) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["message"]) + setLoading(prev => ({ ...prev, loadingMessages: new Map(prev.loadingMessages).set(instanceId, new Set(["other"])) })) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["message"]) + setActive(false) + assert.equal(store.getMessage("message"), undefined) + assert.deepEqual(store.getSessionMessageIds(sessionId), []) + } finally { + dispose() + messageStoreBus.unregisterInstance(instanceId) + setSessions(previousSessions) + setLoading(previousLoading) + } +}) diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..f68740d0e 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,17 +1,14 @@ -import { createEffect, createSignal, 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") - -const SESSION_CACHE_LIMIT = 5 +import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js" +import { + reconcileSessionTranscriptBudget, + setSessionTranscriptVisible, +} from "../../../stores/session-transcript-memory" type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + isActiveInstance: Accessor } type SessionCacheState = { @@ -19,80 +16,27 @@ type SessionCacheState = { } export function useSessionCache(options: SessionCacheOptions): SessionCacheState { - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - 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) - } - - const scheduleEvictions = (ids: string[]) => { - if (!ids.length) return - setPendingEvictions((current) => { - const existing = new Set(current) - const next = [...current] - ids.forEach((id) => { - if (!existing.has(id)) { - next.push(id) - existing.add(id) - } - }) - return next - }) - } - - createEffect(() => { - const pending = pendingEvictions() - if (!pending.length) return - const cached = new Set(cachedSessionIds()) - const remaining: string[] = [] - pending.forEach((id) => { - if (cached.has(id)) { - remaining.push(id) - } else { - evictSession(id) - } - }) - if (remaining.length !== pending.length) { - setPendingEvictions(remaining) - } - }) - - createEffect(() => { + const visibleSessionId = createMemo(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() + if (!options.isActiveInstance() || !activeId || activeId === "info" || !instanceSessions.has(activeId)) return null + return activeId + }) + const cachedSessionIds = createMemo(() => { + const sessionId = visibleSessionId() + return sessionId ? [sessionId] : [] + }) - setCachedSessionIds((current) => { - const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) - - const touch = (id: string | null) => { - if (!id || id === "info") return - if (!instanceSessions.has(id)) return - - const index = next.indexOf(id) - if (index !== -1) { - next.splice(index, 1) - } - next.unshift(id) - } - - touch(activeId) - - const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next + // Enforcement reads session/loading state. Those reads must not become + // visibility dependencies and briefly unpin an unchanged visible identity. + createEffect(on([options.instanceId, visibleSessionId], ([instanceId, sessionId]) => { + if (!sessionId) return + setSessionTranscriptVisible(instanceId, sessionId, true) + onCleanup(() => setSessionTranscriptVisible(instanceId, sessionId, false)) + })) - const trimmedSet = new Set(trimmed) - const removed = current.filter((id) => !trimmedSet.has(id)) - if (removed.length) { - scheduleEvictions(removed) - } - return trimmed - }) + onCleanup(() => { + reconcileSessionTranscriptBudget() }) return { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 91d40793d..ca2af1514 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,9 +1,10 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { useGlobalCache } from "../lib/hooks/use-global-cache" import type { TextPart, RenderCache } from "../types/message" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useI18n } from "../lib/i18n" +import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils" const log = getLogger("session") @@ -89,6 +90,10 @@ function renderFallbackHtml(content: string): string { return escapeHtml(content).replace(/\n/g, "
") } +export function getMarkdownTextForRender(content: string): string { + return limitToolOutputForRender(content) +} + interface MarkdownProps { part: TextPart instanceId?: string @@ -158,7 +163,7 @@ export function Markdown(props: MarkdownProps) { const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" - const text = decodeHtmlEntitiesLocally(rawText) + const text = decodeHtmlEntitiesLocally(getMarkdownTextForRender(rawText)) const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const escapeRawHtml = Boolean(props.escapeRawHtml) @@ -346,15 +351,31 @@ export function Markdown(props: MarkdownProps) { }) return ( -
+ <> +
+ TOOL_OUTPUT_RENDER_CHARACTER_LIMIT}> + + + ) } diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx index 2fd9b018c..9110d532c 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -22,6 +22,15 @@ import { copyToClipboard } from "../lib/clipboard" import SpeechActionButton from "./speech-action-button" import type { VisibilityPreference } from "../stores/preferences" import type { ToolState, ToolStateCompleted, ToolStateError, ToolStateRunning } from "../types/tool-state" +import { + clearInstanceMessageRenderCaches, + clearSessionMessageRenderCache, + getSessionMessageRenderCache, + peekSessionMessageRenderCache, + purgeMessageRenderCache, + extractReasoningTextForCopy, +} from "../lib/message-render-cache" +import { accountSessionTranscript } from "../stores/session-transcript-memory" import { parseReasoningSummary } from "../lib/reasoning-summary" import { getFormQueue } from "../stores/forms" import { backgroundSession, deleteMessagePart, deleteTechnicalPartGroup } from "../stores/session-actions" @@ -127,47 +136,27 @@ interface CachedBlockEntry { toolKeys: string[] } -interface SessionRenderCache { - messageItems: Map - toolItems: Map - messageBlocks: Map -} - -const renderCaches = new Map() - -function makeSessionCacheKey(instanceId: string, sessionId: string) { - return `${instanceId}:${sessionId}` -} - export function clearSessionRenderCache(instanceId: string, sessionId: string) { - renderCaches.delete(makeSessionCacheKey(instanceId, sessionId)) + clearSessionMessageRenderCache(instanceId, sessionId) } -function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache { - const key = makeSessionCacheKey(instanceId, sessionId) - let cache = renderCaches.get(key) - if (!cache) { - cache = { - messageItems: new Map(), - toolItems: new Map(), - messageBlocks: new Map(), - } - renderCaches.set(key, cache) +function clearMessageRenderCache(instanceId: string, sessionId: string, messageIds: readonly string[]) { + const cache = peekSessionMessageRenderCache(instanceId, sessionId) + if (!cache) return + purgeMessageRenderCache(cache, messageIds) + if (cache.messageBlocks.size === 0 && cache.messageItems.size === 0 && cache.toolItems.size === 0) { + clearSessionMessageRenderCache(instanceId, sessionId) } - return cache } function clearInstanceCaches(instanceId: string) { clearRecordDisplayCacheForInstance(instanceId) - const prefix = `${instanceId}:` - for (const key of renderCaches.keys()) { - if (key.startsWith(prefix)) { - renderCaches.delete(key) - } - } + clearInstanceMessageRenderCaches(instanceId) } messageStoreBus.onInstanceDestroyed(clearInstanceCaches) +messageStoreBus.onSessionCleared(clearSessionRenderCache) +messageStoreBus.onMessagesRemoved(clearMessageRenderCache) function removeSearchMarks(root: HTMLElement) { const marks = Array.from(root.querySelectorAll("mark.session-search-match")) @@ -251,6 +240,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partIds: string[] } interface ToolDisplayItem { @@ -266,6 +256,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partIds: string[] messageIndex: number onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void @@ -302,18 +293,12 @@ function MessageContentItem(props: MessageContentItemProps) { const parts = createMemo(() => { const current = record() if (!current) return [] - const ids = current.partIds - const startIndex = ids.indexOf(props.startPartId) - if (startIndex === -1) return [] - const resolved: ClientPart[] = [] - for (let idx = startIndex; idx < ids.length; idx++) { - const partId = ids[idx] + for (const partId of props.partIds) { const part = current.parts[partId]?.data if (!part) continue if (!isSupportedPartType(part)) continue - - if (!isContentPartType((part as any).type)) break + if (!isContentPartType((part as any).type)) continue resolved.push(part) } @@ -391,6 +376,7 @@ function MessageContentItem(props: MessageContentItemProps) { interface ToolCallItemProps { instanceId: string sessionId: string + isActive?: Accessor store: () => InstanceMessageStore messageId: string partId: string @@ -491,6 +477,7 @@ function ToolCallItem(props: ToolCallItemProps) { partVersion={partVersion()} instanceId={props.instanceId} sessionId={props.sessionId} + isActive={props.isActive} onContentRendered={props.onContentRendered} headerAction={isBackgroundableTool(toolPart()) ? ( +
+ +
+ + )} ) } @@ -1076,6 +1105,7 @@ interface ExplorationGroupProps { completed: boolean instanceId: string sessionId: string + isActive?: Accessor store: () => InstanceMessageStore pendingFormToolTargets: ReadonlySet activePartId?: string @@ -1177,6 +1207,7 @@ function ExplorationGroup(props: ExplorationGroupProps) { { + const part = record.parts[partId]?.data + return part ? [part] : [] + }) +} + interface StepCardProps { kind: "start" | "finish" part: ClientPart @@ -1596,6 +1634,9 @@ function ReasoningGroupCard(props: { const renderCard = (item: ReasoningDisplayPart) => ( extractReasoningTextForCopy( + messageStoreBus.getOrCreate(props.instanceId).getMessage(item.messageId)?.parts[item.partId]?.data, + )} messageInfo={item.messageInfo} durationMs={item.durationMs} instanceId={props.instanceId} @@ -1666,6 +1707,7 @@ function ReasoningGroupCard(props: { interface ReasoningCardProps { part: ClientPart + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1801,14 +1843,14 @@ function ReasoningCard(props: ReasoningCardProps) { } const speech = useSpeech({ - id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`, + id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.part.id || "reasoning"}`, text: reasoningText, }) const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() const handleCopyReasoning = async () => { - const text = reasoningText() + const text = props.copyText() if (!text.trim()) return await copyToClipboard(text) } diff --git a/packages/ui/src/components/message-history-pagination.test.ts b/packages/ui/src/components/message-history-pagination.test.ts index 1624a0a49..75c8a7303 100644 --- a/packages/ui/src/components/message-history-pagination.test.ts +++ b/packages/ui/src/components/message-history-pagination.test.ts @@ -325,7 +325,7 @@ describe("message history pagination", () => { assert.deepEqual(locatorCalls, ["middle", "middle"]) const source = fs.readFileSync(new URL("./message-section.tsx", import.meta.url), "utf8") - assert.match(source, /batch\(\(\) => \{\s*setSearchMatches\(page\.hits\.map[\s\S]*?setActiveSearchIndex\(0\)[\s\S]*?setIsSearchPending\(false\)\s*\}\)/) + assert.match(source, /batch\(\(\) => \{\s*setSearchMatches\(page\.hits(?:\.filter\([^)]*\))?\.map[\s\S]*?setActiveSearchIndex\(0\)[\s\S]*?setIsSearchPending\(false\)\s*\}\)/) dispose() }) }) diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index ac052e3bc..f2998547d 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -19,7 +19,7 @@ import { copyToClipboard } from "../lib/clipboard" import { showToastNotification } from "../lib/notifications" import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message" -import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" +import { buildRecordDisplayData, getRecordDisplayPartIds } from "../stores/message-v2/record-display-cache" import { getMessageSelectionActionPosition } from "../lib/message-selection-position" import { findHistoryMatches } from "../stores/session-history" import HistoryStatistics from "./history-statistics" @@ -31,8 +31,8 @@ import { createSessionOutlineProjection } from "./session-outline-projection" import SessionCleanupProgress from "./session-cleanup-progress" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" -import { createSearchLocatorAuthority, getMessageWindowPageKey, hasMessageSearchAuthority, loadPagesUntilAnchor, MESSAGE_HISTORY_TRAVERSAL_PAGE_LIMIT } from "./message-history-pagination" -import { isLatestWindow, toWindowSnapshot } from "../stores/message-v2/message-window" +import { isLatestWindow, preserveMessageWindowCursor, toWindowSnapshot } from "../stores/message-v2/message-window" +import { createSearchLocatorAuthority, getMessageWindowPageKey, hasMessageSearchAuthority, loadPagesUntilAnchor } from "./message-history-pagination" import { getLogger } from "../lib/logger" import { beginMessageHistoryTraversal, invalidateMessageHistoryTraversal } from "../stores/session-api" import { getOpenCodeInstanceGeneration, getOpenCodeMutationRevision } from "../stores/opencode-data" @@ -71,11 +71,11 @@ export interface MessageSectionProps { onQuoteSelection?: (text: string, mode: "quote" | "code") => void onReloadMessages?: () => void hasMoreMessages?: boolean - onLoadMoreMessages?: (signal?: AbortSignal) => Promise - onLoadNewerMessages?: (signal?: AbortSignal) => Promise - onLoadLatestMessages?: (signal?: AbortSignal) => Promise - onLoadOldestMessages?: (signal?: AbortSignal) => Promise - onLoadMessageAnchor?: (messageId: string, signal?: AbortSignal) => Promise + onLoadMoreMessages?: (signal?: AbortSignal) => Promise + onLoadNewerMessages?: (signal?: AbortSignal) => Promise + onLoadLatestMessages?: (signal?: AbortSignal) => Promise + onLoadOldestMessages?: (signal?: AbortSignal) => Promise + onLoadMessageAnchor?: (messageId: string, signal?: AbortSignal) => Promise getMessageHistoryCursor?: () => string | undefined isActive?: boolean sessionStreamingActive?: boolean @@ -225,12 +225,13 @@ export default function MessageSection(props: MessageSectionProps) { const resolvedStore = store() const record = resolvedStore.getMessage(messageId) if (!record) return "" - const groups = Array.from(new Set(record.partIds.flatMap((partId) => { + const displayPartIds = getRecordDisplayPartIds(record) + const groups = Array.from(new Set(displayPartIds.flatMap((partId) => { const group = technicalGroupForPart(messageId, partId) return group ? [group.signature] : [] }))).join(";") const pendingForms = pendingFormToolTargets() - const tools = record.partIds.flatMap((partId) => { + const tools = displayPartIds.flatMap((partId) => { const part = record.parts[partId]?.data if (part?.type !== "tool") return [] const pending = Boolean( @@ -517,14 +518,24 @@ export default function MessageSection(props: MessageSectionProps) { const snapshot = overlayWindowOnSnapshot(options?.snapshot ?? listApi()?.captureScrollSnapshot()) if (snapshot) { setLastGoodScrollSnapshot(sessionId, snapshot) - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) + const resolvedStore = store() + resolvedStore.setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, preserveMessageWindowCursor( + snapshot, + resolvedStore.getScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE), + resolvedStore.getMessageWindow(sessionId), + )) return } } const lastGoodScrollSnapshot = getLastGoodScrollSnapshot(sessionId) if (lastGoodScrollSnapshot) { - store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, lastGoodScrollSnapshot) + const resolvedStore = store() + resolvedStore.setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, preserveMessageWindowCursor( + lastGoodScrollSnapshot, + resolvedStore.getScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE), + resolvedStore.getMessageWindow(sessionId), + )) return } @@ -668,7 +679,7 @@ export default function MessageSection(props: MessageSectionProps) { hasAnchor: () => visibleMessageIds().includes(snapshot.anchorKey!), hasMore: () => Boolean(props.hasMoreMessages), isCurrent: isCurrentRestore, - loadMore: props.onLoadMoreMessages!, + loadMore: async () => { await props.onLoadMoreMessages?.() }, getCursor: () => props.getMessageHistoryCursor?.(), }) } catch (error) { @@ -925,8 +936,8 @@ export default function MessageSection(props: MessageSectionProps) { if (!isCurrent()) return const previousPage = messageWindowPageKey() const previousPosition = direction === "older" || direction === "newer" ? api.captureScrollSnapshot() : undefined - await load(controller.signal) - if (!isCurrent()) return + const committed = await load(controller.signal) + if (committed === false || !isCurrent()) return // An empty boundary probe retires the older cursor without changing the // resident page. Do not jump from its top back to its bottom in that case. if (direction === "older" && messageWindowPageKey() === previousPage) return @@ -1000,6 +1011,7 @@ export default function MessageSection(props: MessageSectionProps) { const query = debouncedSearchQuery() const workspace = searchWorkspace() const technical = includeTechnical() + const systemVisibility = preferences().systemMessagesVisibility const cursor = searchPageCursor() const mutationRevision = getOpenCodeMutationRevision(props.instanceId, props.sessionId) const instanceGeneration = getOpenCodeInstanceGeneration(props.instanceId) @@ -1036,7 +1048,7 @@ export default function MessageSection(props: MessageSectionProps) { frame = requestAnimationFrame(() => { if (!isCurrentSearch()) return batch(() => { - setSearchMatches(page.hits.map(hit => ({ + setSearchMatches(page.hits.filter(hit => hit.role !== "system" || systemVisibility !== "hidden").map(hit => ({ id: `${hit.sessionID}:${hit.messageID}:${hit.partIndex}`, sessionId: hit.sessionID, messageId: hit.messageID, partType: hit.kind, role: hit.role === "user" ? "user" : "assistant", start: 0, end: query.length, @@ -1343,6 +1355,7 @@ export default function MessageSection(props: MessageSectionProps) { messageId={messageId} instanceId={props.instanceId} sessionId={props.sessionId} + isActive={isActive} store={store} messageIndex={index()} showThinking={() => preferences().showThinkingBlocks} diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 1c0be9f7d..a482e9060 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,10 +11,13 @@ import { import { activeSessionId, ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants" +import { getPermissionDiffPayload, isPermissionApprovalBlocked } from "./tool-call/permission-block" import FormRequest from "./form-request" import { getFormQueue, type FormInfo } from "../stores/forms" import { sendFormCancel, sendFormReply } from "../stores/instances" import { shouldRenderFormInFallback } from "./form-request-tool-target" +import { createPermissionDiffReviews } from "./permission-diff-review" +import { PermissionFallbackDiff } from "./permission-fallback-diff" const LazyToolCall = lazy(() => import("./tool-call")) @@ -141,6 +144,7 @@ const PermissionApprovalModal: Component = (props) if (!permissionId) return if (permissionSubmitting().has(permissionId)) return + if (response !== "reject" && isPermissionApprovalBlocked(getPermissionDiffPayload(permission), diffReview(permission)?.reviewed() ?? false)) return setPermissionBusy(permissionId, true) setPermissionItemError(permissionId, null) @@ -161,6 +165,7 @@ const PermissionApprovalModal: Component = (props) } const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) + const diffReview = createPermissionDiffReviews(() => props.instanceId, permissionQueue) const formQueue = createMemo(() => getFormQueue(props.instanceId)) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) @@ -353,6 +358,9 @@ const PermissionApprovalModal: Component = (props) {primaryTitle()} + + {(review) => } +