diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 061afb608..9e91dea0a 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -226,7 +226,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 @@ -235,6 +235,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/unified-picker-path.test.ts packages/ui/src/components/virtual-follow-behavior.test.ts @@ -242,6 +244,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 @@ -250,7 +253,9 @@ 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/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 @@ -286,12 +291,14 @@ 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/tool-call/renderer-copy.test.ts packages/ui/src/components/prompt-input/usePromptState.test.ts packages/ui/src/components/session/provider-usage-panel.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts 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/opencode-data.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 3ba43fdc6..2dda0e8f7 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -667,6 +667,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.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts index 35ffe5c78..2c9cfd4fe 100644 --- a/packages/ui/src/components/instance/shell/useSessionCache.ts +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -1,17 +1,15 @@ -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, onCleanup, type Accessor } from "solid-js" +import { + reconcileSessionTranscriptBudget, + setSessionTranscriptVisible, + touchSessionTranscript, +} from "../../../stores/session-transcript-memory" type SessionCacheOptions = { instanceId: Accessor instanceSessions: Accessor> activeSessionId: Accessor + isActiveInstance: Accessor } type SessionCacheState = { @@ -19,80 +17,25 @@ 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 cachedSessionIds = createMemo(() => { const instanceSessions = options.instanceSessions() const activeId = options.activeSessionId() + if (!options.isActiveInstance() || !activeId || activeId === "info" || !instanceSessions.has(activeId)) return [] + return [activeId] + }) - 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 + createEffect(() => { + const instanceId = options.instanceId() + const [sessionId] = cachedSessionIds() + if (!sessionId) return + setSessionTranscriptVisible(instanceId, sessionId, true) + touchSessionTranscript(instanceId, sessionId) + reconcileSessionTranscriptBudget() + 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 66c386e54..67c41f679 100644 --- a/packages/ui/src/components/message-block.tsx +++ b/packages/ui/src/components/message-block.tsx @@ -21,6 +21,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" @@ -126,47 +135,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")) @@ -250,6 +239,7 @@ interface ContentDisplayItem { key: string messageId: string startPartId: string + partIds: string[] } interface ToolDisplayItem { @@ -265,6 +255,7 @@ interface MessageContentItemProps { store: () => InstanceMessageStore messageId: string startPartId: string + partIds: string[] messageIndex: number onRevert?: (messageId: string) => void onFork?: (messageId?: string) => void @@ -301,18 +292,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) } @@ -561,8 +546,10 @@ type CompactionDisplayItem = { type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | ExplorationDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem interface MessageDisplayBlock { - record: MessageRecord + messageId: string + status: MessageRecord["status"] items: MessageBlockItem[] + truncated: boolean } interface MessageBlockProps { @@ -600,7 +587,11 @@ export default function MessageBlock(props: MessageBlockProps) { const { t } = useI18n() const record = createMemo(() => props.store().getMessage(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) - const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) + const sessionCache = getSessionMessageRenderCache(props.instanceId, props.sessionId) as { + messageItems: Map + toolItems: Map + messageBlocks: Map + } const [blockRef, setBlockRef] = createSignal() const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId)) const activeSearchMatch = () => props.activeSearchMatch?.() ?? null @@ -614,6 +605,12 @@ export default function MessageBlock(props: MessageBlockProps) { const pendingFormToolTargets = () => props.pendingFormToolTargets?.() ?? localPendingFormToolTargets() const technicalCleanupPartKeys = () => props.technicalCleanupPartKeys?.() ?? new Set() let lastInlineScrolledSearchMatchId: string | null = null + const handleContentRendered = () => { + props.onContentRendered?.() + accountSessionTranscript(props.instanceId, props.sessionId) + } + + onCleanup(() => accountSessionTranscript(props.instanceId, props.sessionId)) createEffect(() => { const query = props.searchQuery?.() ?? "" @@ -690,8 +687,11 @@ export default function MessageBlock(props: MessageBlockProps) { key: segmentKey, messageId: current.id, startPartId, + partIds: pendingParts.flatMap((part) => typeof part.id === "string" ? [part.id] : []), } sessionCache.messageItems.set(segmentKey, cached) + } else { + cached.partIds = pendingParts.flatMap((part) => typeof part.id === "string" ? [part.id] : []) } items.push(cached) @@ -832,7 +832,7 @@ export default function MessageBlock(props: MessageBlockProps) { flushContent() - const resultBlock: MessageDisplayBlock = { record: current, items } + const resultBlock: MessageDisplayBlock = { messageId: current.id, status: current.status, items, truncated: displayData.truncated } sessionCache.messageBlocks.set(current.id, { signature: cacheSignature, displayData, @@ -840,6 +840,7 @@ export default function MessageBlock(props: MessageBlockProps) { contentKeys: blockContentKeys.slice(), toolKeys: blockToolKeys.slice(), }) + accountSessionTranscript(props.instanceId, props.sessionId) const messagePrefix = `${current.id}:` for (const [key] of sessionCache.messageItems) { @@ -894,19 +895,21 @@ export default function MessageBlock(props: MessageBlockProps) { .filter(isDisplayItemVisible) .map((item) => item.key))) return ( - 0}> -
{ - setBlockRef(element) - onCleanup(() => setBlockRef(undefined)) - }} - class="message-stream-block" - data-message-id={block()!.record.id} - data-search-result={isSearchResult() ? "true" : undefined} - data-search-active={isActiveSearchResult() ? "true" : undefined} - > - - {(item, index) => ( + + {(resolvedBlock) => ( + +
{ + setBlockRef(element) + onCleanup(() => setBlockRef(undefined)) + }} + class="message-stream-block" + data-message-id={resolvedBlock().messageId} + data-search-result={isSearchResult() ? "true" : undefined} + data-search-active={isActiveSearchResult() ? "true" : undefined} + > + + {(item) => ( @@ -949,7 +953,7 @@ export default function MessageBlock(props: MessageBlockProps) { store={props.store} messageId={(item() as ToolDisplayItem).messageId} partId={(item() as ToolDisplayItem).partId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -976,7 +980,7 @@ export default function MessageBlock(props: MessageBlockProps) { ? (expanded) => props.setTechnicalGroupExpanded?.((item() as ExplorationDisplayItem).technicalGroup!.id, expanded) : undefined} technicalCleanupPartKeys={technicalCleanupPartKeys} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} />
@@ -1001,19 +1005,19 @@ export default function MessageBlock(props: MessageBlockProps) { instanceId={props.instanceId} sessionId={props.sessionId} messageId={props.messageId} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} /> @@ -1025,10 +1029,10 @@ export default function MessageBlock(props: MessageBlockProps) { completed={(item() as ReasoningDisplayItem).completed} instanceId={props.instanceId} sessionId={props.sessionId} - status={block()!.record.status} + status={resolvedBlock().status} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded} - onContentRendered={props.onContentRendered} + onContentRendered={handleContentRendered} activePartId={activeSearchMatch()?.partId} showHeader={technicalGroupStartsHere((item() as ReasoningDisplayItem).technicalGroup)} expanded={(item() as ReasoningDisplayItem).technicalGroup @@ -1044,9 +1048,28 @@ export default function MessageBlock(props: MessageBlockProps) { /> - )} -
-
+ )} + + +
+ {t("toolCall.output.truncated")} + +
+
+
+ + )} ) } @@ -1246,6 +1269,13 @@ function ExplorationGroup(props: ExplorationGroupProps) { ) } +function orderedMessageParts(record: MessageRecord): ClientPart[] { + return record.partIds.flatMap((partId) => { + const part = record.parts[partId]?.data + return part ? [part] : [] + }) +} + interface StepCardProps { kind: "start" | "finish" part: ClientPart @@ -1578,6 +1608,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} @@ -1648,6 +1681,7 @@ function ReasoningGroupCard(props: { interface ReasoningCardProps { part: ClientPart + copyText: () => string messageInfo?: MessageInfo durationMs?: number instanceId: string @@ -1783,14 +1817,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-section.tsx b/packages/ui/src/components/message-section.tsx index 2176e2ac2..642e9ee1d 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -17,11 +17,12 @@ 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 { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" +import { preserveMessageWindowCursor } from "../stores/message-v2/message-window" import { createSearchLocatorAuthority, getMessageWindowPageKey, hasMessageSearchAuthority, loadCompleteMessageHistory, loadPagesUntilAnchor, MESSAGE_HISTORY_TRAVERSAL_PAGE_LIMIT, reconcileResidentSearchMatches } from "./message-history-pagination" import { isLatestWindow, toWindowSnapshot } from "../stores/message-v2/message-window" import { getLogger } from "../lib/logger" @@ -62,10 +63,10 @@ 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 + onLoadMoreMessages?: (signal?: AbortSignal) => Promise + onLoadNewerMessages?: (signal?: AbortSignal) => Promise + onLoadLatestMessages?: (signal?: AbortSignal) => Promise + onLoadOldestMessages?: (signal?: AbortSignal) => Promise getMessageHistoryCursor?: () => string | undefined isActive?: boolean sessionStreamingActive?: boolean @@ -211,12 +212,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( @@ -468,14 +470,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 } @@ -605,7 +617,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) { @@ -833,8 +845,8 @@ export default function MessageSection(props: MessageSectionProps) { try { if (!isCurrent()) return const previousPage = messageWindowPageKey() - await load(controller.signal) - if (!isCurrent()) return + const committed = await load(controller.signal) + if (!committed || !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 @@ -926,8 +938,8 @@ export default function MessageSection(props: MessageSectionProps) { getPageKey: messageWindowPageKey, isCurrent: isCurrentSearch, isLatest: () => isLatestWindow(store().getMessageWindow(sessionId)), - loadOldest: props.onLoadOldestMessages ?? (() => Promise.resolve()), - loadNewer: props.onLoadNewerMessages ?? (() => Promise.resolve()), + loadOldest: async () => { await props.onLoadOldestMessages?.() }, + loadNewer: async () => { await props.onLoadNewerMessages?.() }, visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }), }).then((matches) => { if (!matches) { @@ -1011,7 +1023,7 @@ export default function MessageSection(props: MessageSectionProps) { isCurrent: () => searchLocatorAuthority.isCurrent(locatorAuthority) && activeSearchMatch()?.id === match.id && isSearchOpen(), - loadMore: props.onLoadNewerMessages ?? (() => Promise.resolve()), + loadMore: async () => { await props.onLoadNewerMessages?.() }, getCursor: messageWindowPageKey, maxPages: MESSAGE_HISTORY_TRAVERSAL_PAGE_LIMIT, }) diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 1c0be9f7d..ecf6d4685 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -11,6 +11,7 @@ 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" @@ -368,10 +369,10 @@ const PermissionApprovalModal: Component = (props)
- - +
) } @@ -405,6 +410,7 @@ function ToolCallDetails(props: { error={permissionError} renderDiff={renderDiffContent} fallbackSessionId={() => props.sessionId} + onApprovalBlockedChange={setPermissionApprovalBlocked} onRespond={(permission, sessionId, response, message) => void handlePermissionResponse(permission, response, message)} /> ) @@ -421,6 +427,10 @@ function ToolCallDetails(props: { await copyToClipboard(text) } + const copyToolInput = async (event: MouseEvent) => { + await copyIoText(event, formatToolInputForCopy(props.toolInput())?.text) + } + const outputWrapTitle = () => props.outputWrapEnabled() ? props.t("toolCall.diff.disableWordWrap") @@ -434,6 +444,7 @@ function ToolCallDetails(props: { copyText?: () => string | null | undefined copyTitle?: () => string copyAriaLabel?: () => string + onCopy?: (event: MouseEvent) => void actions?: () => JSXElement }) => (
@@ -449,18 +460,16 @@ function ToolCallDetails(props: { {(actions) => {actions()}} - - {(copyText) => ( - - )} + +
) @@ -547,7 +556,7 @@ function ToolCallDetails(props: { language: () => toolInputDisplay()?.language, expanded: props.inputSectionExpanded, onToggle: props.toggleInputSection, - copyText: () => toolInputDisplay()?.copyText, + onCopy: copyToolInput, copyTitle: () => props.t("toolCall.io.copyInputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyInputAriaLabel"), }) @@ -572,6 +581,7 @@ function ToolCallDetails(props: { expanded: props.outputSectionExpanded, onToggle: props.toggleOutputSection, copyText: () => outputChrome().copyText, + onCopy: canCopyOutput() ? (event) => void copyIoText(event, resolveOutputCopyText()) : undefined, copyTitle: () => props.t("toolCall.io.copyOutputTitle"), copyAriaLabel: () => props.t("toolCall.io.copyOutputAriaLabel"), actions: () => outputChrome().actions, @@ -706,7 +716,9 @@ export default function ToolCall(props: ToolCallProps) { const hasToolInput = createMemo(() => { const input = toolInput() - return input && Object.keys(input).length > 0 + if (!input) return false + for (const key in input) if (Object.prototype.hasOwnProperty.call(input, key)) return true + return false }) const [toolCallRootEl, setToolCallRootEl] = createSignal() @@ -719,11 +731,7 @@ export default function ToolCall(props: ToolCallProps) { if (override !== undefined) return override return diagnosticsDefaultExpanded() } - const diagnosticsEntries = createMemo(() => { - const state = toolState() - if (!state) return [] - return extractDiagnostics(state) - }) + const diagnosticsView = createMemo(() => extractDiagnosticsView(toolState())) const toggleInputSection = () => { setInputSectionOverride((prev) => { @@ -833,6 +841,7 @@ export default function ToolCall(props: ToolCallProps) { } const toolTypeLabel = createMemo(() => toolName()) + const renderedToolTypeLabel = createMemo(() => limitToolTitleForRender(toolTypeLabel())) const headerTitleDetail = createMemo(() => { const rawTitle = renderToolTitle().trim() @@ -854,9 +863,10 @@ export default function ToolCall(props: ToolCallProps) { const detail = headerTitleDetail() return [typeLabel, detail].filter(Boolean).join(" ") }) + const renderedHeaderTitleDetail = createMemo(() => limitToolTitleForRender(headerTitleDetail())) - const headerCopyText = createMemo(() => headerOutputChrome().copyText || "") - const canCopyHeaderOutput = () => headerCopyText().length > 0 + const headerCopyText = () => headerOutputChrome().copyText || headerOutputChrome().getCopyText?.() || "" + const canCopyHeaderOutput = () => headerOutputChrome().hasCopyText ?? Boolean(headerOutputChrome().copyText || headerOutputChrome().getCopyText) const speechText = createMemo(() => buildToolSpeechText({ title: headerText(), @@ -952,8 +962,8 @@ export default function ToolCall(props: ToolCallProps) { >