diff --git a/.github/workflows/comment-pr-artifacts.yml b/.github/workflows/comment-pr-artifacts.yml
index e6383bcf..e296828b 100644
--- a/.github/workflows/comment-pr-artifacts.yml
+++ b/.github/workflows/comment-pr-artifacts.yml
@@ -32,7 +32,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- if [ "$BASE_REF" = "dev" ]; then
+ if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml
index a87e1674..20762487 100644
--- a/.github/workflows/pr-build.yml
+++ b/.github/workflows/pr-build.yml
@@ -32,7 +32,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- if [ "$BASE_REF" = "dev" ]; then
+ if [ "$BASE_REF" = "dev" ] || [ "$BASE_REF" = "DEV-v2" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -103,13 +103,21 @@ jobs:
- name: Test changed runnable UI behavior
run: >-
- node --import tsx --test
+ node --import tsx --test --test-force-exit
+ packages/ui/src/components/markdown-render-limit.test.ts
+ packages/ui/src/components/message-block-render-limit.test.ts
packages/ui/src/components/session-list-visibility.test.ts
+ packages/ui/src/components/tool-call/render-memory.test.ts
packages/ui/src/components/unified-picker-path.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-foreground-refresh.test.ts
packages/ui/src/lib/launch-errors.test.ts
+ packages/ui/src/lib/message-render-cache.test.ts
packages/ui/src/lib/message-selection-position.test.ts
+ packages/ui/src/lib/retained-size.test.ts
+ packages/ui/src/lib/session-transcript-lru.test.ts
+ packages/ui/src/lib/session-transcript-measurement.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
@@ -120,12 +128,14 @@ jobs:
packages/ui/src/stores/restore-workspace-commit-gates.test.ts
packages/ui/src/stores/client-state-codec.test.ts
packages/ui/src/stores/client-state.test.ts
+ packages/ui/src/stores/delta-buffer.test.ts
packages/ui/src/stores/instances-restore-cancellation.test.ts
packages/ui/src/stores/message-v2/instance-store.test.ts
packages/ui/src/stores/message-v2/message-hydration-authority.test.ts
packages/ui/src/stores/message-v2/message-status.test.ts
packages/ui/src/stores/message-v2/normalizers.test.ts
packages/ui/src/stores/session-generation-recovery.test.ts
+ packages/ui/src/stores/session-message-pages.test.ts
packages/ui/src/stores/session-pagination.test.ts
packages/ui/src/types/session.test.ts
packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts
@@ -133,9 +143,12 @@ jobs:
- name: Test restore ownership integration
run: >-
node --conditions=browser --import tsx --test --test-force-exit
+ packages/ui/src/components/tool-call/renderer-copy.test.ts
packages/ui/src/lib/hooks/use-active-session-message-load.test.ts
packages/ui/src/stores/instances-restore-ownership.test.ts
+ packages/ui/src/stores/message-v2/bus.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
+ packages/ui/src/stores/session-native-events.test.ts
packages/ui/src/stores/session-actions.test.ts
packages/ui/src/stores/session-request-authority.test.ts
packages/ui/src/stores/session-send-lifecycle.test.ts
diff --git a/.github/workflows/restrict-non-dev-prs.yml b/.github/workflows/restrict-non-dev-prs.yml
index ab27f943..d8367109 100644
--- a/.github/workflows/restrict-non-dev-prs.yml
+++ b/.github/workflows/restrict-non-dev-prs.yml
@@ -14,7 +14,7 @@ permissions:
jobs:
restrict-non-dev-prs:
- if: ${{ github.event.pull_request.base.ref != 'dev' }}
+ if: ${{ github.event.pull_request.base.ref != 'dev' && github.event.pull_request.base.ref != 'DEV-v2' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
@@ -39,7 +39,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
- gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
+ gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target the \`dev\` or \`DEV-v2\` branch. Please retarget this PR to an authorized development branch."
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
diff --git a/packages/ui/src/components/markdown-render-limit.test.ts b/packages/ui/src/components/markdown-render-limit.test.ts
new file mode 100644
index 00000000..1837e242
--- /dev/null
+++ b/packages/ui/src/components/markdown-render-limit.test.ts
@@ -0,0 +1,17 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { getMarkdownTextForRender } from "./markdown.tsx"
+import { TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils.ts"
+
+test("ordinary markdown is bounded before parsing or escaping", () => {
+ const rendered = getMarkdownTextForRender(`${"<".repeat(20_000)}COPY_TAIL`)
+ assert.equal(rendered.length, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT)
+ assert.equal(rendered.includes("COPY_TAIL"), false)
+ assert.equal(rendered.includes("Output truncated for rendering"), true)
+})
+
+test("truncated markdown retains the full original outside the parse input", () => {
+ const source = `${"x".repeat(20_000)}COPY_TAIL`
+ assert.equal(getMarkdownTextForRender(source).includes("COPY_TAIL"), false)
+ assert.equal(source.includes("COPY_TAIL"), true)
+})
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 91d40793..ca2af151 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-render-limit.test.ts b/packages/ui/src/components/message-block-render-limit.test.ts
new file mode 100644
index 00000000..8a185447
--- /dev/null
+++ b/packages/ui/src/components/message-block-render-limit.test.ts
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import {
+ extractReasoningTextForCopy,
+ extractReasoningTextForRender,
+ extractReasoningTitleForRender,
+ REASONING_RENDER_CHARACTER_LIMIT,
+ REASONING_RENDER_NODE_LIMIT,
+ REASONING_TITLE_CHARACTER_LIMIT,
+} from "../lib/message-render-cache.ts"
+import { buildRecordDisplayData, MESSAGE_PART_DISPLAY_LIMIT } from "../stores/message-v2/record-display-cache.ts"
+import type { MessageRecord } from "../stores/message-v2/types.ts"
+
+test("caps parts before traversing or cloning the record tail", () => {
+ const accessed: string[] = []
+ const partIds = Array.from({ length: MESSAGE_PART_DISPLAY_LIMIT + 1 }, (_, index) => `part-${index}`)
+ const parts = new Proxy({}, {
+ get(_target, partId: string) {
+ accessed.push(partId)
+ return { revision: 1, data: { id: partId, type: "text", text: partId } }
+ },
+ })
+ const record = { id: "bounded-message", revision: 1, partIds, parts } as unknown as MessageRecord
+
+ const display = buildRecordDisplayData("render-limit-test", record)
+
+ assert.equal(display.orderedParts.length, MESSAGE_PART_DISPLAY_LIMIT)
+ assert.equal(display.truncated, true)
+ assert.equal(accessed.length, MESSAGE_PART_DISPLAY_LIMIT)
+ assert.equal(accessed.includes(`part-${MESSAGE_PART_DISPLAY_LIMIT}`), false)
+})
+
+test("bounds reasoning display work while copy remains authoritative and lazy", () => {
+ const tail = "COPY_ONLY_TAIL"
+ const part = { type: "reasoning", text: `${"x".repeat(REASONING_RENDER_CHARACTER_LIMIT)}${tail}` }
+ const rendered = extractReasoningTextForRender(part)
+
+ assert.equal(rendered.length, REASONING_RENDER_CHARACTER_LIMIT)
+ assert.equal(rendered.includes(tail), false)
+ assert.equal(extractReasoningTextForCopy(part).endsWith(tail), true)
+ assert.equal(extractReasoningTitleForRender(`\n**bounded title**\n${"t".repeat(REASONING_TITLE_CHARACTER_LIMIT)}`), "bounded title")
+})
+
+test("stops traversing nested reasoning at the display node budget", () => {
+ let furthestIndex = -1
+ const content = new Proxy(Array.from({ length: REASONING_RENDER_NODE_LIMIT }, (_, index) => String(index)), {
+ get(target, key, receiver) {
+ if (typeof key === "string" && /^\d+$/.test(key)) furthestIndex = Number(key)
+ return Reflect.get(target, key, receiver)
+ },
+ })
+
+ extractReasoningTextForRender({ type: "reasoning", content })
+
+ assert.ok(furthestIndex >= 0)
+ assert.ok(furthestIndex < content.length - 1)
+})
diff --git a/packages/ui/src/components/message-block.tsx b/packages/ui/src/components/message-block.tsx
index 3ef4e11e..08f93790 100644
--- a/packages/ui/src/components/message-block.tsx
+++ b/packages/ui/src/components/message-block.tsx
@@ -20,11 +20,20 @@ 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,
+ extractReasoningTitleForRender,
+} from "../lib/message-render-cache"
+import { accountSessionTranscript } from "../stores/session-transcript-memory"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const NO_STEP_BORDER = "none"
-
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
@@ -53,38 +62,6 @@ function extractTaskSessionId(state: ToolState | undefined): string {
return typeof directId === "string" ? directId : ""
}
-function reasoningHasRenderableContent(part: ClientPart): boolean {
- if (!part || part.type !== "reasoning") {
- return false
- }
- const checkSegment = (segment: unknown): boolean => {
- if (typeof segment === "string") {
- return segment.trim().length > 0
- }
- if (segment && typeof segment === "object") {
- const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
- if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
- return true
- }
- if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
- return true
- }
- if (Array.isArray(candidate.content)) {
- return candidate.content.some((entry) => checkSegment(entry))
- }
- }
- return false
- }
-
- if (checkSegment((part as any).text)) {
- return true
- }
- if (Array.isArray((part as any).content)) {
- return (part as any).content.some((entry: unknown) => checkSegment(entry))
- }
- return false
-}
-
interface TaskSessionLocation {
sessionId: string
instanceId: string
@@ -132,48 +109,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 && cache.recordDisplayCache.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"))
@@ -257,6 +213,7 @@ interface ContentDisplayItem {
key: string
messageId: string
startPartId: string
+ partCount: number
}
interface ToolDisplayItem {
@@ -272,6 +229,7 @@ interface MessageContentItemProps {
store: () => InstanceMessageStore
messageId: string
startPartId: string
+ partCount: number
messageIndex: number
lastAssistantIndex: () => number
onRevert?: (messageId: string) => void
@@ -315,7 +273,7 @@ function MessageContentItem(props: MessageContentItemProps) {
if (startIndex === -1) return []
const resolved: ClientPart[] = []
- for (let idx = startIndex; idx < ids.length; idx++) {
+ for (let idx = startIndex; idx < ids.length && resolved.length < props.partCount; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
@@ -478,7 +436,7 @@ interface StepDisplayItem {
type ReasoningDisplayItem = {
type: "reasoning"
key: string
- part: ClientPart
+ text: string
messageInfo?: MessageInfo
durationMs?: number
showAgentMeta?: boolean
@@ -500,8 +458,9 @@ type CompactionDisplayItem = {
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
interface MessageDisplayBlock {
- record: MessageRecord
+ messageId: string
items: MessageBlockItem[]
+ truncated: boolean
}
interface MessageBlockProps {
@@ -527,12 +486,23 @@ 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
+ recordDisplayCache: Map
+ }
let blockRef: HTMLDivElement | undefined
const isSearchResult = () => Boolean(props.searchResultMessageIds?.().has(props.messageId))
const activeSearchMatch = () => props.activeSearchMatch?.() ?? null
const isActiveSearchResult = () => activeSearchMatch()?.messageId === props.messageId
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?.() ?? ""
@@ -582,7 +552,9 @@ export default function MessageBlock(props: MessageBlockProps) {
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
- const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
+ const displayData = buildRecordDisplayData(props.instanceId, current)
+ sessionCache.recordDisplayCache.set(current.id, { revision: current.revision, data: displayData })
+ const { orderedParts } = displayData
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
@@ -611,8 +583,11 @@ export default function MessageBlock(props: MessageBlockProps) {
key: segmentKey,
messageId: current.id,
startPartId,
+ partCount: pendingParts.length,
}
sessionCache.messageItems.set(segmentKey, cached)
+ } else {
+ cached.partCount = pendingParts.length
}
items.push(cached)
@@ -690,7 +665,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "reasoning") {
flushContent()
- if (props.showThinking() && reasoningHasRenderableContent(part)) {
+ const text = typeof (part as any).text === "string" ? (part as any).text : ""
+ if (props.showThinking() && text.trim().length > 0) {
const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
@@ -700,7 +676,7 @@ export default function MessageBlock(props: MessageBlockProps) {
items.push({
type: "reasoning",
key,
- part,
+ text,
messageInfo: info,
durationMs: inferReasoningDurationMs(orderedParts, part, info, current.status),
showAgentMeta,
@@ -718,13 +694,14 @@ export default function MessageBlock(props: MessageBlockProps) {
flushContent()
- const resultBlock: MessageDisplayBlock = { record: current, items }
+ const resultBlock: MessageDisplayBlock = { messageId: current.id, items, truncated: displayData.truncated }
sessionCache.messageBlocks.set(current.id, {
signature: cacheSignature,
block: resultBlock,
contentKeys: blockContentKeys.slice(),
toolKeys: blockToolKeys.slice(),
})
+ accountSessionTranscript(props.instanceId, props.sessionId)
const messagePrefix = `${current.id}:`
for (const [key] of sessionCache.messageItems) {
@@ -755,13 +732,13 @@ export default function MessageBlock(props: MessageBlockProps) {
return (
{(resolvedBlock) => (
-
+
{
blockRef = el
}}
class="message-stream-block"
- data-message-id={resolvedBlock().record.id}
+ data-message-id={resolvedBlock().messageId}
data-search-result={isSearchResult() ? "true" : undefined}
data-search-active={isActiveSearchResult() ? "true" : undefined}
>
@@ -774,12 +751,13 @@ export default function MessageBlock(props: MessageBlockProps) {
sessionId={props.sessionId}
store={props.store}
messageId={(item() as ContentDisplayItem).messageId}
- startPartId={(item() as ContentDisplayItem).startPartId}
+ startPartId={(item() as ContentDisplayItem).startPartId}
+ partCount={(item() as ContentDisplayItem).partCount}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
onRevert={props.onRevert}
onFork={props.onFork}
- onContentRendered={props.onContentRendered}
+ onContentRendered={handleContentRendered}
/>
@@ -794,7 +772,7 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
- onContentRendered={props.onContentRendered}
+ onContentRendered={handleContentRendered}
/>
@@ -822,7 +800,7 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
- onContentRendered={props.onContentRendered}
+ onContentRendered={handleContentRendered}
/>
@@ -837,7 +815,11 @@ export default function MessageBlock(props: MessageBlockProps) {
extractReasoningTextForCopy(
+ props.store().getMessage((item() as ReasoningDisplayItem).messageId)?.parts[(item() as ReasoningDisplayItem).partId]?.data,
+ )}
messageInfo={(item() as ReasoningDisplayItem).messageInfo}
durationMs={(item() as ReasoningDisplayItem).durationMs}
instanceId={props.instanceId}
@@ -845,13 +827,30 @@ export default function MessageBlock(props: MessageBlockProps) {
messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
- onContentRendered={props.onContentRendered}
+ onContentRendered={handleContentRendered}
forceExpanded={activeSearchMatch()?.partId === (item() as ReasoningDisplayItem).partId}
/>
)}
+
+
+ {t("toolCall.output.truncated")}
+
+
+
)}
@@ -859,6 +858,13 @@ export default function MessageBlock(props: MessageBlockProps) {
)
}
+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
@@ -1063,7 +1069,9 @@ function formatCostValue(value: number) {
}
interface ReasoningCardProps {
- part: ClientPart
+ text: string
+ partId: string
+ copyText: () => string
messageInfo?: MessageInfo
durationMs?: number
instanceId: string
@@ -1171,51 +1179,9 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID
}
- const reasoningText = () => {
- const part = props.part as any
- if (!part) return ""
+ const reasoningText = () => props.text
- const stringifySegment = (segment: unknown): string => {
- if (typeof segment === "string") {
- return segment
- }
- if (segment && typeof segment === "object") {
- const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
- const pieces: string[] = []
- if (typeof obj.text === "string") {
- pieces.push(obj.text)
- }
- if (typeof obj.value === "string") {
- pieces.push(obj.value)
- }
- if (Array.isArray(obj.content)) {
- pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
- }
- return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
- }
- return ""
- }
-
- const textValue = stringifySegment(part.text)
- if (textValue.trim().length > 0) {
- return textValue
- }
- if (Array.isArray(part.content)) {
- return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
- }
- return ""
- }
-
- const extractedTitle = () => {
- const firstLine = reasoningText()
- .split(/\r?\n/)
- .map((line: string) => line.trim())
- .find((line: string) => line.length > 0)
- if (!firstLine) return ""
-
- const match = firstLine.match(/^\*\*([^*]+)\*\*/)
- return match?.[1]?.trim() ?? ""
- }
+ const extractedTitle = () => extractReasoningTitleForRender(reasoningText())
const thoughtDurationTitle = () => {
const duration = props.durationMs
@@ -1257,14 +1223,14 @@ function ReasoningCard(props: ReasoningCardProps) {
const toggle = () => setExpanded((prev) => !prev)
const speech = useSpeech({
- id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
+ id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${props.partId || "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)
}
@@ -1296,7 +1262,7 @@ function ReasoningCard(props: ReasoningCardProps) {
return (
-
- {t("messageSection.search.partialNotice", { count: String(searchMatches().length) })}
-
= SEARCH_MIN_CHARS && isSearchPending()}>
{t("messageSection.search.searching")}
diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx
index 221be2d9..b2de5f11 100644
--- a/packages/ui/src/components/permission-approval-modal.tsx
+++ b/packages/ui/src/components/permission-approval-modal.tsx
@@ -13,9 +13,7 @@ import {
} from "../stores/instances"
import { ensureSessionAncestorsExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
-import { isPermissionDiffTooLarge, PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants"
-import { copyToClipboard } from "../lib/clipboard"
-import { limitToolTitleForRender } from "./tool-call/utils"
+import { PERMISSION_REJECT_REASON_MAX_LENGTH } from "./tool-call/permission-constants"
const LazyToolCall = lazy(() => import("./tool-call"))
@@ -141,20 +139,6 @@ const PermissionApprovalModal: Component = (props)
const [permissionSubmitting, setPermissionSubmitting] = createSignal>(new Set())
const [permissionError, setPermissionError] = createSignal