Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId: () => props.instance.id,
instanceSessions: allInstanceSessions,
activeSessionId: activeSessionIdForInstance,
visible: () => Boolean(props.isActiveInstance),
})

const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen())
Expand Down
34 changes: 19 additions & 15 deletions packages/ui/src/components/instance/shell/useSessionCache.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
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

type SessionCacheOptions = {
instanceId: Accessor<string>
instanceSessions: Accessor<Map<string, unknown>>
activeSessionId: Accessor<string | null>
visible: Accessor<boolean>
}

type SessionCacheState = {
Expand All @@ -23,13 +20,8 @@ export function useSessionCache(options: SessionCacheOptions): SessionCacheState
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])

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[]) => {
Expand All @@ -50,20 +42,32 @@ 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) {
setPendingEvictions(remaining)
}
})

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()
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/message-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
15 changes: 12 additions & 3 deletions packages/ui/src/components/message-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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([])
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1585,7 +1594,7 @@ export default function MessageSection(props: MessageSectionProps) {
</Show>
</Show>

<Show when={props.loading}>
<Show when={props.loading && visibleMessageIds().length === 0}>
<div class="loading-state">
<div class="spinner" />
<p>{t("messageSection.loading.messages")}</p>
Expand Down
7 changes: 7 additions & 0 deletions packages/ui/src/components/session/session-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -431,6 +433,11 @@ export const SessionView: Component<SessionViewProps> = (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) {
Expand Down
52 changes: 38 additions & 14 deletions packages/ui/src/components/tool-call.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -111,6 +115,7 @@ function ToolCallDetails(props: {
toolCallIdentifier: () => string
instanceId: string
sessionId: string
visibilitySessionId: string
messageId?: string
messageVersion?: number
partVersion?: number
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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}
/>
Expand Down Expand Up @@ -475,7 +485,7 @@ function ToolCallDetails(props: {
if (state?.status === "error" && state.error) {
return (
<div class="tool-call-error-content">
<strong>{props.t("toolCall.error.label")}</strong> {state.error}
<strong>{props.t("toolCall.error.label")}</strong> {limitToolOutputForRender(state.error)}
</div>
)
}
Expand Down Expand Up @@ -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")
Expand All @@ -535,6 +557,7 @@ function ToolCallDetails(props: {
copyText?: () => string | null | undefined
copyTitle?: () => string
copyAriaLabel?: () => string
onCopy?: (event: MouseEvent) => void
actions?: () => JSXElement
wrapToggle?: () => boolean | undefined
}) => (
Expand All @@ -551,18 +574,16 @@ function ToolCallDetails(props: {
{(actions) => <span class="tool-call-io-actions">{actions()}</span>}
</Show>

<Show when={options.copyText?.()}>
{(copyText) => (
<button
type="button"
class="tool-call-header-icon-button tool-call-header-copy tool-call-io-copy"
onClick={(event) => void copyIoText(event, copyText())}
aria-label={options.copyAriaLabel?.() ?? props.t("toolCall.io.copyOutputAriaLabel")}
title={options.copyTitle?.() ?? props.t("toolCall.io.copyOutputTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
<Show when={Boolean(options.copyText?.() || options.onCopy)}>
<button
type="button"
class="tool-call-header-icon-button tool-call-header-copy tool-call-io-copy"
onClick={(event) => options.onCopy ? options.onCopy(event) : void copyIoText(event, options.copyText?.())}
aria-label={options.copyAriaLabel?.() ?? props.t("toolCall.io.copyOutputAriaLabel")}
title={options.copyTitle?.() ?? props.t("toolCall.io.copyOutputTitle")}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>

<Show when={options.wrapToggle?.()}>
Expand Down Expand Up @@ -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"),
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions packages/ui/src/components/tool-call/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions packages/ui/src/components/tool-call/diff-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -100,7 +102,7 @@ export function createDiffContentRenderer(params: {
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
if (
cached
&& cached.text === payload.diffText
&& cached.text === renderedDiffText
&& cached.theme === themeKey
&& cached.mode === currentMode()
&& cached.wrap === currentWrap()
Expand Down Expand Up @@ -172,12 +174,14 @@ export function createDiffContentRenderer(params: {
</button>
</div>
</div>
{cachedHtml() ? (
{diffWasTruncated ? (
<pre class="tool-call-diff-fallback">{renderedDiffText}</pre>
) : cachedHtml() ? (
<CachedDiffMarkup html={cachedHtml()!} onRendered={handleDiffRendered} />
) : (
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
<Suspense fallback={<pre class="tool-call-diff-fallback">{renderedDiffText}</pre>}>
<LazyToolCallDiffViewer
diffText={payload.diffText}
diffText={renderedDiffText}
filePath={payload.filePath}
theme={themeKey}
mode={currentMode()}
Expand Down
Loading
Loading