perf(app): virtualize session timeline rows (#26949)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-05-18 13:40:52 +10:00 committed by GitHub
parent e85119aa64
commit 5452ab6db7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1618 additions and 883 deletions

View File

@ -764,7 +764,7 @@
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "5.8.2", "typescript": "5.8.2",
"ulid": "3.0.1", "ulid": "3.0.1",
"virtua": "0.42.3", "virtua": "0.49.1",
"vite": "7.1.4", "vite": "7.1.4",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"zod": "4.1.8", "zod": "4.1.8",
@ -4904,7 +4904,7 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"virtua": ["virtua@0.42.3", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-5FoAKcEvh05qsUF97Yz42SWJ7bwnPExjUYHGuoxz1EUtfWtaOgXaRwnylJbDpA0QcH1rKvJ2qsGRi9MK1fpQbg=="], "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="],
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="], "vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],

View File

@ -74,7 +74,7 @@
"shiki": "3.20.0", "shiki": "3.20.0",
"solid-list": "0.3.0", "solid-list": "0.3.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"virtua": "0.42.3", "virtua": "0.49.1",
"vite": "7.1.4", "vite": "7.1.4",
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",

View File

@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { useSearchParams } from "@solidjs/router" import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session" import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = { type SessionHistoryWindowInput = {
sessionID: () => string | undefined sessionID: () => string | undefined
messagesReady: () => boolean
loaded: () => number loaded: () => number
visibleUserMessages: () => UserMessage[] visibleUserMessages: () => UserMessage[]
historyMore: () => boolean historyMore: () => boolean
@ -85,205 +84,74 @@ type SessionHistoryWindowInput = {
scroller: () => HTMLDivElement | undefined scroller: () => HTMLDivElement | undefined
} }
/** function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
* Maintains the rendered history window for a session timeline. const historyScrollThreshold = 200
* let shiftFrame: number | undefined
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
const [state, setState] = createStore({ const [state, setState] = createStore({
turnID: undefined as string | undefined, shift: false,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
}) })
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) const userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
equals: same,
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
}) })
const setTurnStart = (start: number) => { const cancelShiftReset = () => {
const id = input.sessionID() if (shiftFrame === undefined) return
const next = start > 0 ? start : 0 cancelAnimationFrame(shiftFrame)
if (!id) { shiftFrame = undefined
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
} }
const renderedUserMessages = createMemo( const scheduleShiftReset = () => {
() => { cancelShiftReset()
const msgs = input.visibleUserMessages() shiftFrame = requestAnimationFrame(() => {
const start = turnStart() shiftFrame = undefined
if (start <= 0) return msgs setState("shift", false)
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
}) })
} }
const backfillTurns = () => { const fetchOlderMessages = async () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
let loaded = input.loaded()
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
let afterVisible = beforeVisible
let added = 0
while (true) {
await input.loadMore(id)
if (input.sessionID() !== id) return
afterVisible = input.visibleUserMessages().length
const nextLoaded = input.loaded()
const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded
if (afterVisible > beforeVisible) break
if (raw <= 0) break
if (!input.historyMore()) break
}
if (added <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, beforeVisible + turnBatch)
setTurnStart(Math.max(0, afterVisible - target))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const id = input.sessionID() const id = input.sessionID()
if (!id) return if (!id) return
if (!input.historyMore() || input.historyLoading()) return if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) { // TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
let loaded = input.loaded() let loaded = input.loaded()
let added = 0
let growth = 0 let growth = 0
cancelShiftReset()
setState("shift", true)
while (true) { while (true) {
await input.loadMore(id) await input.loadMore(id)
if (input.sessionID() !== id) return if (input.sessionID() !== id) return
const nextLoaded = input.loaded() const nextLoaded = input.loaded()
const raw = nextLoaded - loaded const raw = nextLoaded - loaded
added += raw
loaded = nextLoaded loaded = nextLoaded
growth = input.visibleUserMessages().length - beforeVisible growth = input.visibleUserMessages().length - beforeVisible
if (growth > 0) break if (growth > 0) break
if (raw <= 0) break if (raw <= 0) break
if (opts?.prefetch) break
if (!input.historyMore()) break if (!input.historyMore()) break
} }
const afterVisible = input.visibleUserMessages().length if (growth > 0) {
scheduleShiftReset()
if (opts?.prefetch) {
setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (added > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (added <= 0) return
if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
return return
} }
if (turnStart() !== start) return setState("shift", false)
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = Math.min(afterVisible, base + turnBatch)
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
} }
const loadAndReveal = () => fetchOlderMessages()
const onScrollerScroll = () => { const onScrollerScroll = () => {
if (!input.userScrolled()) return if (!input.userScrolled()) return
const el = input.scroller() const el = input.scroller()
if (!el) return if (!el) return
if (el.scrollTop >= turnScrollThreshold) return if (el.scrollTop >= historyScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
void fetchOlderMessages() void fetchOlderMessages()
} }
@ -292,27 +160,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
on( on(
input.sessionID, input.sessionID,
() => { () => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) cancelShiftReset()
setState({ shift: false })
}, },
{ defer: true }, { defer: true },
), ),
) )
createEffect( onCleanup(cancelShiftReset)
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
return { return {
turnStart, userMessages,
setTurnStart, shift: () => state.shift,
renderedUserMessages,
loadAndReveal, loadAndReveal,
onScrollerScroll, onScrollerScroll,
} }
@ -333,6 +192,7 @@ export default function Page() {
const comments = useComments() const comments = useComments()
const terminal = useTerminal() const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout() const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => { createEffect(() => {
@ -737,6 +597,7 @@ export default function Page() {
let dockHeight = 0 let dockHeight = 0
let scroller: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
let revealMessage = (_id: string) => {}
let scrollMark = 0 let scrollMark = 0
let messageMark = 0 let messageMark = 0
@ -1403,9 +1264,8 @@ export default function Page() {
}, },
) )
const historyWindow = createSessionHistoryWindow({ const historyLoader = createSessionHistoryLoader({
sessionID: () => params.id, sessionID: () => params.id,
messagesReady,
loaded: () => messages().length, loaded: () => messages().length,
visibleUserMessages, visibleUserMessages,
historyMore, historyMore,
@ -1427,9 +1287,9 @@ export default function Page() {
const el = scroller const el = scroller
if (!el) return if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return if (!historyMore()) return
void historyWindow.loadAndReveal() void historyLoader.loadAndReveal()
}) })
} }
@ -1439,15 +1299,14 @@ export default function Page() {
[ [
params.id, params.id,
messagesReady(), messagesReady(),
historyWindow.turnStart(),
historyMore(), historyMore(),
historyLoading(), historyLoading(),
autoScroll.userScrolled(), autoScroll.userScrolled(),
visibleUserMessages().length, visibleUserMessages().length,
] as const, ] as const,
([id, ready, start, more, loading, scrolled]) => { ([id, ready, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return if (!more) return
fill() fill()
}, },
{ defer: true }, { defer: true },
@ -1749,15 +1608,14 @@ export default function Page() {
historyMore, historyMore,
historyLoading, historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID), loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId, currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage, pendingMessage: () => ui.pendingMessage,
setPendingMessage: (value) => setUi("pendingMessage", value), setPendingMessage: (value) => setUi("pendingMessage", value),
setActiveMessage, setActiveMessage,
setTurnStart: historyWindow.setTurnStart,
autoScroll, autoScroll,
scroller: () => scroller, scroller: () => scroller,
anchor, anchor,
revealMessage: (id) => revealMessage(id),
scheduleScrollState, scheduleScrollState,
consumePendingMessage: layout.pendingMessage.consume, consumePendingMessage: layout.pendingMessage.consume,
}) })
@ -1830,20 +1688,23 @@ export default function Page() {
> >
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}> <Match when={params.id}>
<Show when={messagesReady()}> <Show when={messagesReady()}>
<MessageTimeline <MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions} actions={actions}
scroll={ui.scroll} scroll={ui.scroll}
onResumeScroll={resumeScroll} onResumeScroll={resumeScroll}
@ -1853,8 +1714,11 @@ export default function Page() {
onMarkScrollGesture={markScrollGesture} onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture} hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll} onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll} onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction} onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()} centered={centered()}
setContentRef={(el) => { setContentRef={(el) => {
content = el content = el
@ -1863,14 +1727,12 @@ export default function Page() {
const root = scroller const root = scroller
if (root) scheduleScrollState(root) if (root) scheduleScrollState(root)
}} }}
turnStart={historyWindow.turnStart()} historyShift={historyLoader.shift()}
historyMore={historyMore()} userMessages={historyLoader.userMessages()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor} anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/> />
</Show> </Show>
</Match> </Match>

View File

@ -0,0 +1,364 @@
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { groupParts, PartGroup, renderable } from "@opencode-ai/ui/message-part"
import { Data, Equal } from "effect"
export type SummaryDiff = SnapshotFileDiff & { file: string }
export type TimelineRowMap = {
CommentStrip: {
userMessageID: string
previousUserMessage: boolean
}
UserMessage: {
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}
TurnDivider: {
userMessageID: string
label: "compaction" | "interrupted"
}
AssistantPart: {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
BottomSpacer: {}
}
export namespace TimelineRow {
export class CommentStrip extends Data.TaggedClass("CommentStrip")<{
userMessageID: string
previousUserMessage: boolean
}> {}
export class UserMessage extends Data.TaggedClass("UserMessage")<{
userMessageID: string
anchor: boolean
previousUserMessage: boolean
}> {}
export class TurnDivider extends Data.TaggedClass("TurnDivider")<{
userMessageID: string
label: "compaction" | "interrupted"
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
lastAssistantPart: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
reasoningHeading?: string
}> {}
export class DiffSummary extends Data.TaggedClass("DiffSummary")<{
userMessageID: string
diffs: SummaryDiff[]
}> {}
export class Error extends Data.TaggedClass("Error")<{
userMessageID: string
text: string
}> {}
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export class BottomSpacer extends Data.TaggedClass("BottomSpacer")<{}> {}
export type TimelineRow =
| CommentStrip
| UserMessage
| TurnDivider
| AssistantPart
| Thinking
| DiffSummary
| Error
| Retry
| BottomSpacer
export const key = (row: TimelineRow) => {
switch (row._tag) {
case "CommentStrip":
return `comment-strip:${row.userMessageID}`
case "UserMessage":
return `user-message:${row.userMessageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}:${row.label}`
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "DiffSummary":
return `diff-summary:${row.userMessageID}`
case "Error":
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
case "BottomSpacer":
return "bottom-spacer"
}
}
export function equals(a: TimelineRow, b: TimelineRow) {
return Equal.equals(a, b)
}
}
export namespace Timeline {
export function constructMessageRows(
userMessage: UserMessage,
getMessageParts: (messageID: string) => Part[],
assistantMessages: AssistantMessage[],
index: number,
showReasoning: boolean,
status: SessionStatus["type"],
isActive: boolean,
) {
const rows: TimelineRow.TimelineRow[] = []
const previousUserMessage = index > 0
const userParts = getMessageParts(userMessage.id)
const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? [])
const compaction = userParts.some((p) => p.type === "compaction")
const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError")
const interrupted = interruptedMessageIndex !== -1
const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error
const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) =>
getMessageParts(message.id)
.filter((part) => renderable(part, showReasoning))
.map((part) => ({ messageID: message.id, messageIndex, part })),
)
const assistantItems =
interrupted && !compaction
? [
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map((group) => ({
type: "part" as const,
group,
})),
{ type: "interrupted" as const },
...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map((group) => ({
type: "part" as const,
group,
})),
]
: groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group }))
const assistantGroupCount = assistantItems.filter((item) => item.type === "part").length
if (comments.length > 0)
rows.push(
new TimelineRow.CommentStrip({
userMessageID: userMessage.id,
previousUserMessage,
}),
)
rows.push(
new TimelineRow.UserMessage({
userMessageID: userMessage.id,
anchor: comments.length === 0,
previousUserMessage: comments.length === 0 && previousUserMessage,
}),
)
if (compaction) {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "compaction",
}),
)
}
let assistantGroupIndex = 0
assistantItems.forEach((item) => {
if (item.type === "interrupted") {
rows.push(
new TimelineRow.TurnDivider({
userMessageID: userMessage.id,
label: "interrupted",
}),
)
return
}
rows.push(
new TimelineRow.AssistantPart({
userMessageID: userMessage.id,
group: item.group,
previousAssistantPart: assistantGroupIndex > 0,
lastAssistantPart: assistantGroupIndex === assistantGroupCount - 1,
}),
)
assistantGroupIndex += 1
})
if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
.find((value): value is string => !!value)
rows.push(
new TimelineRow.Thinking({
userMessageID: userMessage.id,
reasoningHeading: heading,
}),
)
}
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
const diffs = (userMessage.summary?.diffs ?? [])
.reduceRight<SummaryDiff[]>((result, diff) => {
if (!isSummaryDiff(diff)) return result
if (result.some((item) => item.file === diff.file)) return result
result.push(diff)
return result
}, [])
.reverse()
if (diffs.length > 0 && (status === "idle" || !isActive)) {
rows.push(
new TimelineRow.DiffSummary({
userMessageID: userMessage.id,
diffs,
}),
)
}
if (error) {
const data = error.data?.message
rows.push(
new TimelineRow.Error({
userMessageID: userMessage.id,
text: unwrapErrorMessage(
typeof data === "string" ? data : data === undefined || data === null ? "" : String(data),
),
}),
)
}
return rows
}
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
function reasoningHeading(text: string) {
const markdown = text.replace(/\r\n?/g, "\n")
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
if (html?.[1]) {
const value = cleanHeading(html[1].replace(/<[^>]+>/g, " "))
if (value) return value
}
const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m)
if (atx?.[1]) {
const value = cleanHeading(atx[1])
if (value) return value
}
const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m)
if (setext?.[1]) {
const value = cleanHeading(setext[1])
if (value) return value
}
const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m)
if (strong?.[1]) {
const value = cleanHeading(strong[1])
if (value) return value
}
}
function cleanHeading(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_~]+/g, "")
.trim()
}
function unwrapErrorMessage(message: string) {
const text = message.replace(/^Error:\s*/, "").trim()
const parse = (value: string) => {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
const read = (value: string) => {
const first = parse(value)
if (typeof first !== "string") return first
return parse(first.trim())
}
let json = read(text)
if (json === undefined) {
const start = text.indexOf("{")
const end = text.lastIndexOf("}")
if (start !== -1 && end > start) json = read(text.slice(start, end + 1))
}
if (!record(json)) return message
const err = record(json.error) ? json.error : undefined
if (err) {
const type = typeof err.type === "string" ? err.type : undefined
const msg = typeof err.message === "string" ? err.message : undefined
if (type && msg) return `${type}: ${msg}`
if (msg) return msg
if (type) return type
const code = typeof err.code === "string" ? err.code : undefined
if (code) return code
}
const msg = typeof json.message === "string" ? json.message : undefined
if (msg) return msg
const reason = typeof json.error === "string" ? json.error : undefined
if (reason) return reason
return message
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
}
export namespace MessageComment {
export type MessageComment = {
path: string
comment: string
selection?: {
startLine: number
endLine: number
}
}
export const fromPart = (part: Part): MessageComment | undefined => {
if (part.type !== "text" || !part.synthetic) return
const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
if (!next) return
return {
path: next.path,
comment: next.comment,
selection: next.selection
? {
startLine: next.selection.startLine,
endLine: next.selection.endLine,
}
: undefined,
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
historyMore: () => boolean historyMore: () => boolean
historyLoading: () => boolean historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void> loadMore: (sessionID: string) => Promise<void>
turnStart: () => number
currentMessageId: () => string | undefined currentMessageId: () => string | undefined
pendingMessage: () => string | undefined pendingMessage: () => string | undefined
setPendingMessage: (value: string | undefined) => void setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; forceScrollToBottom: () => void } autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string anchor: (id: string) => string
revealMessage?: (id: string) => void
scheduleScrollState: (el: HTMLDivElement) => void scheduleScrollState: (el: HTMLDivElement) => void
consumePendingMessage: (key: string) => string | undefined consumePendingMessage: (key: string) => string | undefined
}) => { }) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages()) const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = "" let pendingKey = ""
let clearing = false let clearing = false
@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
} }
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
input.revealMessage?.(id)
const el = document.getElementById(input.anchor(id)) const el = document.getElementById(input.anchor(id))
if (el) return scrollToElement(el, behavior) if (el) return scrollToElement(el, behavior)
if (left <= 0) return false if (left <= 0) return false
@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
cancel() cancel()
if (input.currentMessageId() !== message.id) input.setActiveMessage(message) if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
input.revealMessage?.(message.id)
const index = messageIndex().get(message.id) ?? -1
if (index !== -1 && index < input.turnStart()) {
input.setTurnStart(index)
queue(() => {
seek(message.id, behavior)
})
updateHash(message.id)
return
}
if (seek(message.id, behavior)) { if (seek(message.id, behavior)) {
updateHash(message.id) updateHash(message.id)
@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
if (!input.sessionID() || !input.messagesReady()) return if (!input.sessionID() || !input.messagesReady()) return
visibleUserMessages() visibleUserMessages()
input.turnStart()
let targetId = input.pendingMessage() let targetId = input.pendingMessage()
if (!targetId) { if (!targetId) {

View File

@ -6,6 +6,7 @@
"exports": { "exports": {
"./package.json": "./package.json", "./package.json": "./package.json",
"./*": "./src/components/*.tsx", "./*": "./src/components/*.tsx",
"./session-diff": "./src/components/session-diff.ts",
"./i18n/*": "./src/i18n/*.ts", "./i18n/*": "./src/i18n/*.ts",
"./pierre": "./src/pierre/index.ts", "./pierre": "./src/pierre/index.ts",
"./pierre/*": "./src/pierre/*.ts", "./pierre/*": "./src/pierre/*.ts",

View File

@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion" import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@ -40,26 +40,76 @@ export interface BasicToolProps {
} }
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
const deferredMounts: Array<{ active: boolean; fn: () => void }> = []
let deferredFrame: number | undefined
function flushDeferredMounts() {
while (deferredMounts.length > 0) {
// Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn.
// Pop from the end so heavy default-open bodies near the bottom become interactive first.
const item = deferredMounts.pop()!
if (item.active) {
deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined
item.fn()
return
}
}
deferredFrame = undefined
}
function scheduleDeferredFlush() {
if (deferredFrame !== undefined) return
deferredFrame = requestAnimationFrame(() => {
deferredFrame = requestAnimationFrame(flushDeferredMounts)
})
}
function scheduleDeferredMount(fn: () => void) {
const item = { active: true, fn }
deferredMounts.push(item)
scheduleDeferredFlush()
return () => {
item.active = false
}
}
function scheduleFrameMount(fn: () => void) {
const frame = requestAnimationFrame(fn)
return () => cancelAnimationFrame(frame)
}
export function BasicTool(props: BasicToolProps) { export function BasicTool(props: BasicToolProps) {
const [state, setState] = createStore({ const [state, setState] = createStore({
open: props.defaultOpen ?? false, open: props.defaultOpen ?? false,
ready: props.defaultOpen ?? false, ready: !props.defer && (props.defaultOpen ?? false),
}) })
const open = () => state.open const open = () => state.open
const ready = () => state.ready const ready = () => state.ready
const pending = () => props.status === "pending" || props.status === "running" const pending = () => props.status === "pending" || props.status === "running"
const hasChildren = () => (props.defer ? "children" in props : props.children)
let frame: number | undefined let cancelReady: (() => void) | undefined
const cancel = () => { const cancel = () => {
if (frame === undefined) return cancelReady?.()
cancelAnimationFrame(frame) cancelReady = undefined
frame = undefined }
const scheduleReady = (initial = false) => {
cancel()
cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
cancelReady = undefined
if (!open()) return
setState("ready", true)
})
} }
onCleanup(cancel) onCleanup(cancel)
onMount(() => {
if (props.defer && open()) scheduleReady(true)
})
createEffect(() => { createEffect(() => {
if (props.forceOpen) setState("open", true) if (props.forceOpen) setState("open", true)
}) })
@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) {
return return
} }
cancel() scheduleReady()
frame = requestAnimationFrame(() => {
frame = undefined
if (!open()) return
setState("ready", true)
})
}, },
{ defer: true }, { defer: true },
), ),
@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) {
</Switch> </Switch>
</div> </div>
</div> </div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}> <Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow /> <Collapsible.Arrow />
</Show> </Show>
</div> </div>
@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
</Collapsible.Trigger> </Collapsible.Trigger>
)} )}
</Show> </Show>
<Show when={props.animated && props.children && !props.hideDetails}> <Show when={props.animated && hasChildren() && !props.hideDetails}>
<div <div
ref={contentRef} ref={contentRef}
data-slot="collapsible-content" data-slot="collapsible-content"
@ -229,10 +274,10 @@ export function BasicTool(props: BasicToolProps) {
overflow: initialOpen ? "visible" : "hidden", overflow: initialOpen ? "visible" : "hidden",
}} }}
> >
{props.children} <Show when={!props.defer || ready()}>{props.children}</Show>
</div> </div>
</Show> </Show>
<Show when={!props.animated && props.children && !props.hideDetails}> <Show when={!props.animated && hasChildren() && !props.hideDetails}>
<Collapsible.Content> <Collapsible.Content>
<Show when={!props.defer || ready()}>{props.children}</Show> <Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content> </Collapsible.Content>

View File

@ -150,6 +150,7 @@ export interface MessagePartProps {
message: MessageType message: MessageType
hideDetails?: boolean hideDetails?: boolean
defaultOpen?: boolean defaultOpen?: boolean
deferToolContent?: boolean
showAssistantCopyPartID?: string | null showAssistantCopyPartID?: string | null
turnDurationMs?: number turnDurationMs?: number
} }
@ -486,12 +487,12 @@ function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
return a.every((x, i) => x === b[i]) return a.every((x, i) => x === b[i])
} }
type PartRef = { export type PartRef = {
messageID: string messageID: string
partID: string partID: string
} }
type PartGroup = export type PartGroup =
| { | {
key: string key: string
type: "part" type: "part"
@ -520,14 +521,14 @@ function sameGroup(a: PartGroup, b: PartGroup) {
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
} }
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { export function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true if (a === b) return true
if (!a || !b) return false if (!a || !b) return false
if (a.length !== b.length) return false if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!)) return a.every((item, i) => sameGroup(item, b[i]!))
} }
function groupParts(parts: { messageID: string; part: PartType }[]) { export function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = [] const result: PartGroup[] = []
let start = -1 let start = -1
@ -575,7 +576,7 @@ function index<T extends { id: string }>(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const)) return new Map(items.map((item) => [item.id, item] as const))
} }
function renderable(part: PartType, showReasoningSummaries = true) { export function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") { if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
@ -591,7 +592,7 @@ function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit
} }
function partDefaultOpen(part: PartType, shell = false, edit = false) { export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit) return toolDefaultOpen(part.tool, shell, edit)
} }
@ -904,7 +905,7 @@ export function AssistantMessageDisplay(props: {
) )
} }
function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { export function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const i18n = useI18n() const i18n = useI18n()
const [open, setOpen] = createSignal(false) const [open, setOpen] = createSignal(false)
const pending = createMemo( const pending = createMemo(
@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
const summary = createMemo(() => contextToolSummary(props.parts)) const summary = createMemo(() => contextToolSummary(props.parts))
return ( return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost" class="tool-collapsible"> <Collapsible
open={open()}
onOpenChange={setOpen}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
>
<Collapsible.Trigger> <Collapsible.Trigger>
<div data-component="context-tool-group-trigger"> <div data-component="context-tool-group-trigger">
<span <span
@ -1077,7 +1084,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
} }
return ( return (
<div data-component="user-message"> <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
<Show when={attachments().length > 0}> <Show when={attachments().length > 0}>
<div data-slot="user-message-attachments"> <div data-slot="user-message-attachments">
<For each={attachments()}> <For each={attachments()}>
@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
message={props.message} message={props.message}
hideDetails={props.hideDetails} hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen} defaultOpen={props.defaultOpen}
deferToolContent={props.deferToolContent}
showAssistantCopyPartID={props.showAssistantCopyPartID} showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs} turnDurationMs={props.turnDurationMs}
/> />
@ -1244,6 +1252,7 @@ export interface ToolProps {
status?: string status?: string
hideDetails?: boolean hideDetails?: boolean
defaultOpen?: boolean defaultOpen?: boolean
deferContent?: boolean
forceOpen?: boolean forceOpen?: boolean
locked?: boolean locked?: boolean
} }
@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
return ( return (
<Show when={!hideQuestion()}> <Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper"> <div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
<Switch> <Switch>
<Match when={part().state.status === "error" && (part().state as any).error}> <Match when={part().state.status === "error" && (part().state as any).error}>
{(error) => { {(error) => {
@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
status={part().state.status} status={part().state.status}
hideDetails={props.hideDetails} hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen} defaultOpen={props.defaultOpen}
deferContent={props.deferToolContent}
/> />
</Match> </Match>
</Switch> </Switch>
@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return ( return (
<Show when={text()}> <Show when={text()}>
<div data-component="text-part"> <div data-component="text-part" data-timeline-part-id={part().id}>
<div data-slot="text-part-body"> <div data-slot="text-part-body">
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}> <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} /> <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
return ( return (
<Show when={text()}> <Show when={text()}>
<div data-component="reasoning-part"> <div data-component="reasoning-part" data-timeline-part-id={part().id}>
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}> <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} /> <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
</Show> </Show>
@ -1913,7 +1923,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="edit-trigger"> <div data-component="edit-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@ -1974,7 +1984,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="write-trigger"> <div data-component="write-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">
@ -2056,7 +2066,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={{ trigger={{
title: i18n.t("ui.tool.patch"), title: i18n.t("ui.tool.patch"),
subtitle: subtitle(), subtitle: subtitle(),
@ -2128,7 +2138,7 @@ ToolRegistry.register({
</Accordion.Trigger> </Accordion.Trigger>
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content> <Accordion.Content>
<Show when={visible()}> <Show when={props.deferContent === false || visible()}>
<div data-component="apply-patch-file-diff"> <div data-component="apply-patch-file-diff">
<Dynamic <Dynamic
component={fileComponent} component={fileComponent}
@ -2153,7 +2163,7 @@ ToolRegistry.register({
<BasicTool <BasicTool
{...props} {...props}
icon="code-lines" icon="code-lines"
defer defer={props.deferContent !== false}
trigger={ trigger={
<div data-component="edit-trigger"> <div data-component="edit-trigger">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title-area">