perf(app): virtualize session timeline rows (#26949)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
@@ -764,7 +764,7 @@
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.2",
|
||||
"ulid": "3.0.1",
|
||||
"virtua": "0.42.3",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@
|
||||
"shiki": "3.20.0",
|
||||
"solid-list": "0.3.0",
|
||||
"tailwindcss": "4.1.11",
|
||||
"virtua": "0.42.3",
|
||||
"virtua": "0.49.1",
|
||||
"vite": "7.1.4",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@solidjs/router": "0.15.4",
|
||||
|
||||
@@ -29,7 +29,7 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
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 { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
loaded: () => number
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
@@ -85,205 +84,74 @@ type SessionHistoryWindowInput = {
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains the rendered history window for a session timeline.
|
||||
*
|
||||
* 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
|
||||
function createSessionHistoryLoader(input: SessionHistoryWindowInput) {
|
||||
const historyScrollThreshold = 200
|
||||
let shiftFrame: number | undefined
|
||||
|
||||
const [state, setState] = createStore({
|
||||
turnID: undefined as string | undefined,
|
||||
turnStart: 0,
|
||||
prefetchUntil: 0,
|
||||
prefetchNoGrowth: 0,
|
||||
shift: false,
|
||||
})
|
||||
|
||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||
|
||||
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 userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
|
||||
equals: same,
|
||||
})
|
||||
|
||||
const setTurnStart = (start: number) => {
|
||||
const id = input.sessionID()
|
||||
const next = start > 0 ? start : 0
|
||||
if (!id) {
|
||||
setState({ turnID: undefined, turnStart: next })
|
||||
return
|
||||
}
|
||||
setState({ turnID: id, turnStart: next })
|
||||
const cancelShiftReset = () => {
|
||||
if (shiftFrame === undefined) return
|
||||
cancelAnimationFrame(shiftFrame)
|
||||
shiftFrame = undefined
|
||||
}
|
||||
|
||||
const renderedUserMessages = createMemo(
|
||||
() => {
|
||||
const msgs = input.visibleUserMessages()
|
||||
const start = turnStart()
|
||||
if (start <= 0) return msgs
|
||||
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 scheduleShiftReset = () => {
|
||||
cancelShiftReset()
|
||||
shiftFrame = requestAnimationFrame(() => {
|
||||
shiftFrame = undefined
|
||||
setState("shift", false)
|
||||
})
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
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 fetchOlderMessages = async () => {
|
||||
const id = input.sessionID()
|
||||
if (!id) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const now = Date.now()
|
||||
if (state.prefetchUntil > now) return
|
||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||
}
|
||||
|
||||
const start = turnStart()
|
||||
// TODO(session-timeline): switch this to core cursor-based part pagination when that API lands.
|
||||
const beforeVisible = input.visibleUserMessages().length
|
||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||
let loaded = input.loaded()
|
||||
let added = 0
|
||||
let growth = 0
|
||||
|
||||
cancelShiftReset()
|
||||
setState("shift", true)
|
||||
|
||||
while (true) {
|
||||
await input.loadMore(id)
|
||||
if (input.sessionID() !== id) return
|
||||
|
||||
const nextLoaded = input.loaded()
|
||||
const raw = nextLoaded - loaded
|
||||
added += raw
|
||||
loaded = nextLoaded
|
||||
growth = input.visibleUserMessages().length - beforeVisible
|
||||
|
||||
if (growth > 0) break
|
||||
if (raw <= 0) break
|
||||
if (opts?.prefetch) break
|
||||
if (!input.historyMore()) break
|
||||
}
|
||||
|
||||
const afterVisible = input.visibleUserMessages().length
|
||||
|
||||
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))
|
||||
if (growth > 0) {
|
||||
scheduleShiftReset()
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
setState("shift", false)
|
||||
}
|
||||
|
||||
const loadAndReveal = () => fetchOlderMessages()
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
if (start <= turnPrefetchBuffer) {
|
||||
void fetchOlderMessages({ prefetch: true })
|
||||
}
|
||||
backfillTurns()
|
||||
return
|
||||
}
|
||||
if (el.scrollTop >= historyScrollThreshold) return
|
||||
|
||||
void fetchOlderMessages()
|
||||
}
|
||||
@@ -292,27 +160,18 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
on(
|
||||
input.sessionID,
|
||||
() => {
|
||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||
cancelShiftReset()
|
||||
setState({ shift: false })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionID(), input.messagesReady()] as const,
|
||||
([id, ready]) => {
|
||||
if (!id || !ready) return
|
||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(cancelShiftReset)
|
||||
|
||||
return {
|
||||
turnStart,
|
||||
setTurnStart,
|
||||
renderedUserMessages,
|
||||
userMessages,
|
||||
shift: () => state.shift,
|
||||
loadAndReveal,
|
||||
onScrollerScroll,
|
||||
}
|
||||
@@ -333,6 +192,7 @@ export default function Page() {
|
||||
const comments = useComments()
|
||||
const terminal = useTerminal()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
createEffect(() => {
|
||||
@@ -737,6 +597,7 @@ export default function Page() {
|
||||
let dockHeight = 0
|
||||
let scroller: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
let revealMessage = (_id: string) => {}
|
||||
let scrollMark = 0
|
||||
let messageMark = 0
|
||||
|
||||
@@ -1403,9 +1264,8 @@ export default function Page() {
|
||||
},
|
||||
)
|
||||
|
||||
const historyWindow = createSessionHistoryWindow({
|
||||
const historyLoader = createSessionHistoryLoader({
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
loaded: () => messages().length,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
@@ -1427,9 +1287,9 @@ export default function Page() {
|
||||
const el = scroller
|
||||
if (!el) 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,
|
||||
messagesReady(),
|
||||
historyWindow.turnStart(),
|
||||
historyMore(),
|
||||
historyLoading(),
|
||||
autoScroll.userScrolled(),
|
||||
visibleUserMessages().length,
|
||||
] as const,
|
||||
([id, ready, start, more, loading, scrolled]) => {
|
||||
([id, ready, more, loading, scrolled]) => {
|
||||
if (!id || !ready || loading || scrolled) return
|
||||
if (start <= 0 && !more) return
|
||||
if (!more) return
|
||||
fill()
|
||||
},
|
||||
{ defer: true },
|
||||
@@ -1749,15 +1608,14 @@ export default function Page() {
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
setActiveMessage,
|
||||
setTurnStart: historyWindow.setTurnStart,
|
||||
autoScroll,
|
||||
scroller: () => scroller,
|
||||
anchor,
|
||||
revealMessage: (id) => revealMessage(id),
|
||||
scheduleScrollState,
|
||||
consumePendingMessage: layout.pendingMessage.consume,
|
||||
})
|
||||
@@ -1830,20 +1688,23 @@ export default function Page() {
|
||||
>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<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}>
|
||||
<Show when={messagesReady()}>
|
||||
<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}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
@@ -1853,8 +1714,11 @@ export default function Page() {
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onHistoryScroll={historyLoader.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
@@ -1863,14 +1727,12 @@ export default function Page() {
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
historyShift={historyLoader.shift()}
|
||||
userMessages={historyLoader.userMessages()}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,36 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Index,
|
||||
Match,
|
||||
Switch,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
mapArray,
|
||||
untrack,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Card } from "@opencode-ai/ui/card"
|
||||
import {
|
||||
ContextToolGroup,
|
||||
Message,
|
||||
MessageDivider,
|
||||
Part as MessagePart,
|
||||
partDefaultOpen,
|
||||
type UserActions,
|
||||
} from "@opencode-ai/ui/message-part"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -10,14 +38,25 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { SessionRetry } from "@opencode-ai/ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
Part as PartType,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { normalize } from "@opencode-ai/ui/session-diff"
|
||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -31,45 +70,54 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { makeTimer } from "@solid-primitives/timer"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
comment: string
|
||||
selection?: {
|
||||
startLine: number
|
||||
endLine: number
|
||||
}
|
||||
}
|
||||
import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyTools: ToolPart[] = []
|
||||
const emptyAssistantMessages: AssistantMessage[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
type UserActions = {
|
||||
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||
|
||||
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }>
|
||||
type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
|
||||
|
||||
function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((key, index) => key === b[index])
|
||||
}
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
if (part.type !== "text" || !(part as TextPart).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,
|
||||
},
|
||||
]
|
||||
})
|
||||
const timelineCacheLimit = 16
|
||||
const timelineFallbackItemSize = 60
|
||||
const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
|
||||
|
||||
const taskDescription = (part: Part, sessionID: string) => {
|
||||
function readTimelineCache(id: string, keys: readonly string[]) {
|
||||
const entry = timelineCache.get(id)
|
||||
if (!entry) return
|
||||
if (sameKeys(entry.keys, keys)) return entry.cache
|
||||
timelineCache.delete(id)
|
||||
}
|
||||
|
||||
function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
|
||||
if (!handle || keys.length === 0) return
|
||||
timelineCache.delete(id)
|
||||
timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
|
||||
while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
}
|
||||
|
||||
function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
|
||||
if (!previous?.length) return rows
|
||||
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
|
||||
return rows.map((row) => {
|
||||
const existing = byKey.get(TimelineRow.key(row))
|
||||
if (!existing) return row
|
||||
return TimelineRow.equals(existing, row) ? existing : row
|
||||
})
|
||||
}
|
||||
|
||||
const taskDescription = (part: PartType, sessionID: string) => {
|
||||
if (part.type !== "tool" || part.tool !== "task") return
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
if (metadata?.sessionId !== sessionID) return
|
||||
@@ -110,106 +158,114 @@ const markBoundaryGesture = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
type StageConfig = {
|
||||
init: number
|
||||
batch: number
|
||||
}
|
||||
function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) {
|
||||
const language = useLanguage()
|
||||
|
||||
type TimelineStageInput = {
|
||||
sessionKey: () => string
|
||||
turnStart: () => number
|
||||
messages: () => UserMessage[]
|
||||
config: StageConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer-mounts small timeline windows so revealing older turns does not
|
||||
* block first paint with a large DOM mount.
|
||||
*
|
||||
* Once staging completes for a session it never re-stages — backfill and
|
||||
* new messages render immediately.
|
||||
*/
|
||||
function createTimelineStaging(input: TimelineStageInput) {
|
||||
const [state, setState] = createStore({
|
||||
activeSession: "",
|
||||
completedSession: "",
|
||||
count: 0,
|
||||
})
|
||||
|
||||
const stagedCount = createMemo(() => {
|
||||
const total = input.messages().length
|
||||
if (input.turnStart() <= 0) return total
|
||||
if (state.completedSession === input.sessionKey()) return total
|
||||
const init = Math.min(total, input.config.init)
|
||||
if (state.count <= init) return init
|
||||
if (state.count >= total) return total
|
||||
return state.count
|
||||
})
|
||||
|
||||
const stagedUserMessages = createMemo(() => {
|
||||
const list = input.messages()
|
||||
const count = stagedCount()
|
||||
if (count >= list.length) return list
|
||||
return list.slice(Math.max(0, list.length - count))
|
||||
})
|
||||
|
||||
let frame: number | undefined
|
||||
const cancel = () => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||
([sessionKey, isWindowed, total]) => {
|
||||
cancel()
|
||||
const shouldStage =
|
||||
isWindowed &&
|
||||
total > input.config.init &&
|
||||
state.completedSession !== sessionKey &&
|
||||
state.activeSession !== sessionKey
|
||||
if (!shouldStage) {
|
||||
setState({ activeSession: "", count: total })
|
||||
return
|
||||
}
|
||||
|
||||
let count = Math.min(total, input.config.init)
|
||||
setState({ activeSession: sessionKey, count })
|
||||
|
||||
const step = () => {
|
||||
if (input.sessionKey() !== sessionKey) {
|
||||
frame = undefined
|
||||
return
|
||||
}
|
||||
const currentTotal = input.messages().length
|
||||
count = Math.min(currentTotal, count + input.config.batch)
|
||||
setState("count", count)
|
||||
if (count >= currentTotal) {
|
||||
setState({ completedSession: sessionKey, activeSession: "" })
|
||||
frame = undefined
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
},
|
||||
),
|
||||
return (
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={language.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!props.showReasoningSummaries}>
|
||||
<TextReveal text={props.reasoningHeading} class="session-turn-thinking-heading" travel={25} duration={700} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isStaging = createMemo(() => {
|
||||
const key = input.sessionKey()
|
||||
return state.activeSession === key && state.completedSession !== key
|
||||
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
|
||||
const language = useLanguage()
|
||||
const maxFiles = 10
|
||||
const [state, setState] = createStore({
|
||||
showAll: false,
|
||||
expanded: [] as string[],
|
||||
})
|
||||
const showAll = () => state.showAll
|
||||
const expanded = () => state.expanded
|
||||
const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles))
|
||||
const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles)))
|
||||
|
||||
onCleanup(cancel)
|
||||
return { messages: stagedUserMessages, isStaging }
|
||||
return (
|
||||
<div
|
||||
data-slot="session-turn-diffs"
|
||||
data-component="session-turn-diffs-group"
|
||||
data-show-all={showAll() || undefined}
|
||||
>
|
||||
<div data-slot="session-turn-diffs-header">
|
||||
<span data-slot="session-turn-diffs-label">
|
||||
{props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "}
|
||||
{language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<DiffChanges changes={props.diffs} />
|
||||
<Show when={overflow() > 0}>
|
||||
<span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
|
||||
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "44px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={visible()}>
|
||||
{(diff) => {
|
||||
const opened = createMemo(() => expanded().includes(diff.file))
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={opened()}>
|
||||
<TimelineDiffView diff={diff} />
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
<Show when={!showAll() && overflow() > 0}>
|
||||
<div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
|
||||
{language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineDiffView(props: { diff: SummaryDiff }) {
|
||||
const fileComponent = useFileComponent()
|
||||
const view = normalize(props.diff)
|
||||
|
||||
return (
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
mobileChanges: boolean
|
||||
mobileFallback: JSX.Element
|
||||
actions?: UserActions
|
||||
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||
onResumeScroll: () => void
|
||||
@@ -219,16 +275,15 @@ export function MessageTimeline(props: {
|
||||
onMarkScrollGesture: (target?: EventTarget | null) => void
|
||||
hasScrollGesture: () => boolean
|
||||
onUserScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onHistoryScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
shouldAnchorBottom: () => boolean
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
turnStart: number
|
||||
historyMore: boolean
|
||||
historyLoading: boolean
|
||||
onLoadEarlier: () => void
|
||||
renderedUserMessages: UserMessage[]
|
||||
historyShift: boolean
|
||||
userMessages: UserMessage[]
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string) => void) => void
|
||||
}) {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
@@ -242,13 +297,27 @@ export function MessageTimeline(props: {
|
||||
const { params, sessionKey } = useSessionKey()
|
||||
const platform = usePlatform()
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
let virtualizer: VirtualizerHandle | undefined
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
})
|
||||
const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const)))
|
||||
const assistantMessagesByParent = createMemo(() => {
|
||||
const result = new Map<string, AssistantMessage[]>()
|
||||
for (const message of sessionMessages()) {
|
||||
if (message.role !== "assistant") continue
|
||||
const messages = result.get(message.parentID)
|
||||
if (messages) {
|
||||
messages.push(message)
|
||||
continue
|
||||
}
|
||||
result.set(message.parentID, [message])
|
||||
}
|
||||
return result
|
||||
})
|
||||
const pending = createMemo(() =>
|
||||
sessionMessages().findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
@@ -317,11 +386,12 @@ export function MessageTimeline(props: {
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
return parentMessages()
|
||||
.flatMap((message) => sync.data.part[message.id] ?? [])
|
||||
.flatMap((message) => getMsgParts(message.id))
|
||||
.map((part) => taskDescription(part, id))
|
||||
.findLast((value): value is string => !!value)
|
||||
})
|
||||
@@ -333,12 +403,139 @@ export function MessageTimeline(props: {
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const stageCfg = { init: 1, batch: 3 }
|
||||
const staging = createTimelineStaging({
|
||||
sessionKey,
|
||||
turnStart: () => props.turnStart,
|
||||
messages: () => props.renderedUserMessages,
|
||||
config: stageCfg,
|
||||
|
||||
const messageRowMemos = createMemo(
|
||||
mapArray(
|
||||
() => props.userMessages,
|
||||
(userMessage, indexAccessor) => {
|
||||
return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
||||
const rows = Timeline.constructMessageRows(
|
||||
userMessage,
|
||||
getMsgParts,
|
||||
assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
|
||||
indexAccessor(),
|
||||
settings.general.showReasoningSummaries(),
|
||||
sessionStatus().type,
|
||||
activeMessageID() === userMessage.id,
|
||||
)
|
||||
|
||||
return reuseTimelineRows(previous, rows)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
|
||||
const rows = messageRowMemos().flatMap((memo) => memo())
|
||||
if (rows.length === 0) return rows
|
||||
return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
|
||||
})
|
||||
const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
|
||||
const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
|
||||
const messageRowIndex = createMemo(() => {
|
||||
const result = new Map<string, number>()
|
||||
timelineRows().forEach((row, index) => {
|
||||
if (!("userMessageID" in row)) return
|
||||
if (result.has(row.userMessageID)) return
|
||||
result.set(row.userMessageID, index)
|
||||
})
|
||||
return result
|
||||
})
|
||||
const keepMounted = createMemo(() => {
|
||||
const id = activeMessageID()
|
||||
if (!id) return
|
||||
const rows = timelineRows()
|
||||
const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
|
||||
if (index < 0) return
|
||||
return [index]
|
||||
})
|
||||
const activeAssistantMessages = createMemo(() => {
|
||||
const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
|
||||
if (!id) return emptyAssistantMessages
|
||||
return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
|
||||
})
|
||||
const activeAssistantContentVersion = createMemo(() =>
|
||||
activeAssistantMessages()
|
||||
.flatMap((message) => [
|
||||
`${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
|
||||
...getMsgParts(message.id).map((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
|
||||
if (part.type === "tool") {
|
||||
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||
const output = "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
|
||||
const metadataOutput =
|
||||
metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
|
||||
? metadata.output.length
|
||||
: 0
|
||||
return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
|
||||
}
|
||||
return `${part.id}:${part.type}`
|
||||
}),
|
||||
])
|
||||
.join("|"),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
|
||||
() => {
|
||||
if (!virtualizer) return
|
||||
if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
|
||||
const keys = timelineRowKeys()
|
||||
if (keys.length === 0) return
|
||||
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
||||
scheduleMeasuredBottomAnchor()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
props.setRevealMessage?.((id) => {
|
||||
const index = messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
virtualizer?.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
})
|
||||
|
||||
let cacheSessionKey = sessionKey()
|
||||
let cacheRowKeys = timelineRowKeys()
|
||||
let virtualizerSessionKey = cacheSessionKey
|
||||
let virtualizerRowKeys = cacheRowKeys
|
||||
let bottomAnchorSessionKey = ""
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (!virtualizer) return
|
||||
const keys = timelineRowKeys()
|
||||
if (keys.length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), timelineRowKeys()] as const,
|
||||
(next, prev) => {
|
||||
if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
|
||||
cacheSessionKey = next[0]
|
||||
cacheRowKeys = next[1]
|
||||
if (virtualizer) {
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
props.setRevealMessage?.(() => {})
|
||||
})
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
@@ -360,14 +557,150 @@ export function MessageTimeline(props: {
|
||||
|
||||
let more: HTMLButtonElement | undefined
|
||||
let head: HTMLDivElement | undefined
|
||||
let listRoot: HTMLDivElement | undefined
|
||||
let listFrame: number | undefined
|
||||
let contentFrame: number | undefined
|
||||
let bottomAnchorFrame: number | undefined
|
||||
let bottomAnchorFrames = 0
|
||||
let measuredBottomAnchored = true
|
||||
const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
|
||||
|
||||
createResizeObserver(
|
||||
() => head,
|
||||
() => {
|
||||
if (!head || head.clientWidth <= 0) return
|
||||
setBar("ms", pace(head.clientWidth))
|
||||
},
|
||||
)
|
||||
const updateTitleMetrics = () => {
|
||||
if (!head || head.clientWidth <= 0) return
|
||||
setBar("ms", pace(head.clientWidth))
|
||||
}
|
||||
|
||||
createResizeObserver(() => head, updateTitleMetrics)
|
||||
|
||||
const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
|
||||
|
||||
function anchorMeasuredBottom() {
|
||||
if (!listRoot) return false
|
||||
if (!measuredBottomAnchored) return false
|
||||
listRoot.scrollTop = listRoot.scrollHeight
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleMeasuredBottomAnchor() {
|
||||
// Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
|
||||
// "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
|
||||
// height, so keep the scroll container bottom-locked for a few frames while measurement settles.
|
||||
bottomAnchorFrames = 90
|
||||
if (bottomAnchorFrame !== undefined) return
|
||||
|
||||
const tick = () => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (!anchorMeasuredBottom()) {
|
||||
bottomAnchorFrames = 0
|
||||
return
|
||||
}
|
||||
|
||||
bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
|
||||
if (bottomAnchorFrames <= 0) return
|
||||
bottomAnchorFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
bottomAnchorFrame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const bindContentRoot = (root: HTMLDivElement) => {
|
||||
const child = root.firstElementChild
|
||||
props.setContentRef(child instanceof HTMLDivElement ? child : root)
|
||||
}
|
||||
|
||||
const scheduleContentRoot = (root: HTMLDivElement) => {
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
contentFrame = requestAnimationFrame(() => {
|
||||
contentFrame = undefined
|
||||
if (listRoot !== root) return
|
||||
bindContentRoot(root)
|
||||
})
|
||||
}
|
||||
|
||||
const connectListRoot = (root: HTMLDivElement) => {
|
||||
if (listRoot !== root) return
|
||||
if (!root.isConnected || !root.ownerDocument.defaultView) {
|
||||
listFrame = requestAnimationFrame(() => {
|
||||
listFrame = undefined
|
||||
connectListRoot(root)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
props.setScrollRef(root)
|
||||
measuredBottomAnchored = isMeasuredBottom(root)
|
||||
setScrollRoot(root)
|
||||
scheduleContentRoot(root)
|
||||
}
|
||||
|
||||
const bindListRoot = (root: HTMLDivElement) => {
|
||||
if (root === listRoot) return
|
||||
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
listRoot = root
|
||||
setScrollRoot(undefined)
|
||||
connectListRoot(root)
|
||||
}
|
||||
|
||||
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const root = event.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: event.deltaY,
|
||||
deltaMode: event.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
touchGesture = event.touches[0]?.clientY
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const next = event.touches[0]?.clientY
|
||||
const prev = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || prev === undefined) return
|
||||
|
||||
const delta = prev - next
|
||||
if (!delta) return
|
||||
|
||||
markBoundaryGesture({
|
||||
root: event.currentTarget,
|
||||
target: event.target,
|
||||
delta,
|
||||
onMarkScrollGesture: props.onMarkScrollGesture,
|
||||
})
|
||||
}
|
||||
|
||||
const handleListTouchEnd = () => {
|
||||
touchGesture = undefined
|
||||
}
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
props.onHistoryScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (listFrame !== undefined) cancelAnimationFrame(listFrame)
|
||||
if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
setScrollRoot(undefined)
|
||||
props.setScrollRef(undefined)
|
||||
})
|
||||
|
||||
const viewShare = () => {
|
||||
const url = shareUrl()
|
||||
@@ -623,496 +956,629 @@ export function MessageTimeline(props: {
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!props.mobileChanges}
|
||||
fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
|
||||
>
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID
|
||||
|
||||
const turnDurationMs = (userMessageID: string) => {
|
||||
const message = messageByID().get(userMessageID)
|
||||
if (!message || message.role !== "user") return
|
||||
const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce<number | undefined>(
|
||||
(max, item) => {
|
||||
const completed = item.time.completed
|
||||
if (typeof completed !== "number") return max
|
||||
if (max === undefined) return completed
|
||||
return Math.max(max, completed)
|
||||
},
|
||||
undefined,
|
||||
)
|
||||
if (typeof end !== "number") return
|
||||
if (end < message.time.created) return
|
||||
return end - message.time.created
|
||||
}
|
||||
|
||||
const assistantCopyPartID = (userMessageID: string) => {
|
||||
if (workingTurn(userMessageID)) return null
|
||||
const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]
|
||||
if (!message) continue
|
||||
|
||||
const parts = getMsgParts(message.id)
|
||||
for (let j = parts.length - 1; j >= 0; j--) {
|
||||
const part = parts[j]
|
||||
if (!part || part.type !== "text" || !part.text?.trim()) continue
|
||||
return part.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
|
||||
const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
|
||||
if (untrack(row).group.type === "context") {
|
||||
const parts = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "context") return emptyTools
|
||||
return group.refs
|
||||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||
.filter((part): part is ToolPart => part?.type === "tool")
|
||||
})
|
||||
|
||||
return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
|
||||
}
|
||||
|
||||
const message = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "part") return
|
||||
return messageByID().get(group.ref.messageID)
|
||||
})
|
||||
const part = createMemo(() => {
|
||||
const group = row().group
|
||||
if (group.type !== "part") return
|
||||
return getMsgPart(group.ref.messageID, group.ref.partID)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<Show when={part()}>
|
||||
{(part) => (
|
||||
<MessagePart
|
||||
part={part()}
|
||||
message={message()}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
defaultOpen={partDefaultOpen(
|
||||
part(),
|
||||
settings.general.shellToolPartsExpanded(),
|
||||
settings.general.editToolPartsExpanded(),
|
||||
)}
|
||||
deferToolContent={false}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
|
||||
const anchor = () => {
|
||||
const row = input.row()
|
||||
return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
|
||||
}
|
||||
const previousUserMessage = () => {
|
||||
const row = input.row()
|
||||
return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
|
||||
}
|
||||
const previousAssistantPart = () => {
|
||||
const row = input.row()
|
||||
return row._tag === "AssistantPart" && row.previousAssistantPart
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
|
||||
data-message-id={input.row().userMessageID}
|
||||
data-timeline-row={input.row()._tag}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
"md:mx-auto": props.centered,
|
||||
"pt-6": previousUserMessage(),
|
||||
"pt-3": previousAssistantPart(),
|
||||
}}
|
||||
>
|
||||
<div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
|
||||
{input.children}
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
const root = e.currentTarget
|
||||
const delta = normalizeWheelDelta({
|
||||
deltaY: e.deltaY,
|
||||
deltaMode: e.deltaMode,
|
||||
rootHeight: root.clientHeight,
|
||||
})
|
||||
if (!delta) return
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
touchGesture = e.touches[0]?.clientY
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const next = e.touches[0]?.clientY
|
||||
const prev = touchGesture
|
||||
touchGesture = next
|
||||
if (next === undefined || prev === undefined) return
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const delta = prev - next
|
||||
if (!delta) return
|
||||
|
||||
const root = e.currentTarget
|
||||
markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture })
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
if (!props.hasScrollGesture()) return
|
||||
props.onUserScroll()
|
||||
props.onAutoScrollHandleScroll()
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
head = el
|
||||
setBar("ms", pace(el.clientWidth))
|
||||
}}
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
relative: true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
||||
<div
|
||||
data-component="session-progress"
|
||||
data-state={workingStatus()}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
||||
"--session-progress-ms": `${bar.ms}ms`,
|
||||
}}
|
||||
>
|
||||
<div data-component="session-progress-bar" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="px-2 text-14-medium text-text-weak"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID()}>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"bg-surface-base-active": share.open || title.pendingShare,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
ref={(el: HTMLButtonElement) => {
|
||||
more = el
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
if (title.pendingShare) {
|
||||
event.preventDefault()
|
||||
requestAnimationFrame(() => {
|
||||
setShare({ open: true, dismiss: null })
|
||||
setTitle("pendingShare", false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.share.action.share")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
|
||||
<KobaltePopover
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
setShare("open", open)
|
||||
}}
|
||||
>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content
|
||||
data-component="popover-content"
|
||||
style={{ "min-width": "320px" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
setShare({ dismiss: "escape", open: false })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setShare({ dismiss: "outside", open: false })
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setShare({ dismiss: "outside", open: false })
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (share.dismiss === "outside") event.preventDefault()
|
||||
setShare("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-13-medium text-text-strong">
|
||||
{language.t("session.share.popover.title")}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
</Show>
|
||||
const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
|
||||
switch (row()._tag) {
|
||||
case "CommentStrip": {
|
||||
const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
|
||||
const comments = createMemo(() =>
|
||||
getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
|
||||
)
|
||||
return (
|
||||
<TimelineRowFrame row={commentStripRow}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(comment) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{getFilename(comment().path)}</span>
|
||||
<Show when={comment().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{comment().comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "UserMessage": {
|
||||
const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
|
||||
const message = createMemo(() => {
|
||||
const m = messageByID().get(userMessageRow().userMessageID)
|
||||
if (m?.role === "user") return m
|
||||
})
|
||||
return (
|
||||
<TimelineRowFrame row={userMessageRow}>
|
||||
<Show when={message()}>
|
||||
{(message) => (
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "TurnDivider": {
|
||||
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
|
||||
return (
|
||||
<TimelineRowFrame row={turnDividerRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-compaction">
|
||||
<MessageDivider
|
||||
label={language.t(
|
||||
turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "AssistantPart": {
|
||||
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
|
||||
return (
|
||||
<TimelineRowFrame row={assistantPartRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
|
||||
{renderAssistantPartGroup(assistantPartRow)}
|
||||
</div>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Thinking": {
|
||||
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
|
||||
return (
|
||||
<TimelineRowFrame row={thinkingRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineThinkingRow
|
||||
reasoningHeading={thinkingRow().reasoningHeading}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
/>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Retry": {
|
||||
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
|
||||
return (
|
||||
<TimelineRowFrame row={retryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "DiffSummary": {
|
||||
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "Error": {
|
||||
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
|
||||
return (
|
||||
<TimelineRowFrame row={errorRow}>
|
||||
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
|
||||
<Card variant="error" class="error-card">
|
||||
{errorRow().text}
|
||||
</Card>
|
||||
</div>
|
||||
</TimelineRowFrame>
|
||||
)
|
||||
}
|
||||
case "BottomSpacer":
|
||||
return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
|
||||
}
|
||||
}
|
||||
|
||||
function TimelineRowView(props: { rowKey: string }) {
|
||||
return (
|
||||
<Show when={timelineRowByKey().get(props.rowKey)}>
|
||||
{(item) => renderTimelineRow(item)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
|
||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<ScrollView
|
||||
viewportRef={bindListRoot}
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
onTouchMove={handleListTouchMove}
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
head = el
|
||||
updateTitleMetrics()
|
||||
}}
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
|
||||
<div
|
||||
data-component="session-progress"
|
||||
data-state={workingStatus()}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
||||
"--session-progress-ms": `${bar.ms}ms`,
|
||||
}}
|
||||
>
|
||||
<div data-component="session-progress-bar" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="px-2 text-14-medium text-text-weak"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
role="log"
|
||||
data-slot="session-turn-list"
|
||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.turnStart > 0 || props.historyMore}>
|
||||
<div class="w-full flex justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="text-12-medium opacity-50"
|
||||
disabled={props.historyLoading}
|
||||
onClick={props.onLoadEarlier}
|
||||
>
|
||||
{props.historyLoading
|
||||
? language.t("session.messages.loadingEarlier")
|
||||
: language.t("session.messages.loadEarlier")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(c, i) =>
|
||||
c.path === b[i].path &&
|
||||
c.comment === b[i].comment &&
|
||||
c.selection?.startLine === b[i].selection?.startLine &&
|
||||
c.selection?.endLine === b[i].selection?.endLine,
|
||||
),
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
style={{
|
||||
"content-visibility": active() ? undefined : "auto",
|
||||
"contain-intrinsic-size": active() ? undefined : "auto 500px",
|
||||
}}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
<div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
|
||||
<div class="flex w-max min-w-full justify-end gap-2">
|
||||
<Index each={comments()}>
|
||||
{(commentAccessor: () => MessageComment) => {
|
||||
const comment = createMemo(() => commentAccessor())
|
||||
return (
|
||||
<Show when={comment()}>
|
||||
{(c) => (
|
||||
<div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
|
||||
<FileIcon
|
||||
node={{ path: c().path, type: "file" }}
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate">{getFilename(c().path)}</span>
|
||||
<Show when={c().selection}>
|
||||
{(selection) => (
|
||||
<span class="shrink-0 text-text-weak">
|
||||
{selection().startLine === selection().endLine
|
||||
? `:${selection().startLine}`
|
||||
: `:${selection().startLine}-${selection().endLine}`}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
|
||||
{c().comment}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={sessionID() ?? ""}
|
||||
messageID={messageID}
|
||||
messages={sessionMessages()}
|
||||
actions={props.actions}
|
||||
active={active()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
container: "w-full px-4 md:px-5",
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID()}>
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"bg-surface-base-active": share.open || title.pendingShare,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||
ref={(el: HTMLButtonElement) => {
|
||||
more = el
|
||||
}}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
if (title.pendingShare) {
|
||||
event.preventDefault()
|
||||
requestAnimationFrame(() => {
|
||||
setShare({ open: true, dismiss: null })
|
||||
setTitle("pendingShare", false)
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("session.share.action.share")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
|
||||
<KobaltePopover
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
setShare("open", open)
|
||||
}}
|
||||
>
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.Content
|
||||
data-component="popover-content"
|
||||
style={{ "min-width": "320px" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
setShare({ dismiss: "escape", open: false })
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDownOutside={() => {
|
||||
setShare({ dismiss: "outside", open: false })
|
||||
}}
|
||||
onFocusOutside={() => {
|
||||
setShare({ dismiss: "outside", open: false })
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (share.dismiss === "outside") event.preventDefault()
|
||||
setShare("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-13-medium text-text-strong">
|
||||
{language.t("session.share.popover.title")}
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{shareUrl()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
>
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextField
|
||||
value={shareUrl() ?? ""}
|
||||
readOnly
|
||||
copyable
|
||||
copyKind="link"
|
||||
tabIndex={-1}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{unshareMutation.isPending
|
||||
? language.t("session.share.action.unpublishing")
|
||||
: language.t("session.share.action.unpublish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={unshareMutation.isPending}
|
||||
>
|
||||
{language.t("session.share.action.view")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={scrollRoot()}>
|
||||
{(root) => (
|
||||
<Virtualizer
|
||||
data={timelineRowKeys()}
|
||||
cache={virtualCache()}
|
||||
itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
|
||||
scrollRef={root()}
|
||||
shift={props.historyShift}
|
||||
keepMounted={keepMounted()}
|
||||
startMargin={64}
|
||||
ref={(handle) => {
|
||||
if (!handle) {
|
||||
writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
|
||||
virtualizer = undefined
|
||||
return
|
||||
}
|
||||
virtualizer = handle
|
||||
virtualizerSessionKey = cacheSessionKey
|
||||
virtualizerRowKeys = cacheRowKeys
|
||||
maybeAnchorBottom()
|
||||
scheduleContentRoot(root())
|
||||
}}
|
||||
>
|
||||
{(key) => <TimelineRowView rowKey={key} />}
|
||||
</Virtualizer>
|
||||
)}
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,21 +11,19 @@ export const useSessionHashScroll = (input: {
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
revealMessage?: (id: string) => void
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
consumePendingMessage: (key: string) => string | undefined
|
||||
}) => {
|
||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||
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 clearing = false
|
||||
|
||||
@@ -77,6 +75,7 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
const seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => {
|
||||
input.revealMessage?.(id)
|
||||
const el = document.getElementById(input.anchor(id))
|
||||
if (el) return scrollToElement(el, behavior)
|
||||
if (left <= 0) return false
|
||||
@@ -89,18 +88,7 @@ export const useSessionHashScroll = (input: {
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
cancel()
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
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
|
||||
}
|
||||
input.revealMessage?.(message.id)
|
||||
|
||||
if (seek(message.id, behavior)) {
|
||||
updateHash(message.id)
|
||||
@@ -154,7 +142,6 @@ export const useSessionHashScroll = (input: {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
input.turnStart()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./*": "./src/components/*.tsx",
|
||||
"./session-diff": "./src/components/session-diff.ts",
|
||||
"./i18n/*": "./src/i18n/*.ts",
|
||||
"./pierre": "./src/pierre/index.ts",
|
||||
"./pierre/*": "./src/pierre/*.ts",
|
||||
|
||||
@@ -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 { useI18n } from "../context/i18n"
|
||||
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 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) {
|
||||
const [state, setState] = createStore({
|
||||
open: props.defaultOpen ?? false,
|
||||
ready: props.defaultOpen ?? false,
|
||||
ready: !props.defer && (props.defaultOpen ?? false),
|
||||
})
|
||||
const open = () => state.open
|
||||
const ready = () => state.ready
|
||||
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 = () => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
cancelReady?.()
|
||||
cancelReady = undefined
|
||||
}
|
||||
|
||||
const scheduleReady = (initial = false) => {
|
||||
cancel()
|
||||
cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => {
|
||||
cancelReady = undefined
|
||||
if (!open()) return
|
||||
setState("ready", true)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(cancel)
|
||||
|
||||
onMount(() => {
|
||||
if (props.defer && open()) scheduleReady(true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.forceOpen) setState("open", true)
|
||||
})
|
||||
@@ -75,12 +125,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
return
|
||||
}
|
||||
|
||||
cancel()
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
if (!open()) return
|
||||
setState("ready", true)
|
||||
})
|
||||
scheduleReady()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -189,7 +234,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
@@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
</Collapsible.Trigger>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<Show when={props.animated && hasChildren() && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="collapsible-content"
|
||||
@@ -229,10 +274,10 @@ export function BasicTool(props: BasicToolProps) {
|
||||
overflow: initialOpen ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && props.children && !props.hideDetails}>
|
||||
<Show when={!props.animated && hasChildren() && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
</Collapsible.Content>
|
||||
|
||||
@@ -150,6 +150,7 @@ export interface MessagePartProps {
|
||||
message: MessageType
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
deferToolContent?: boolean
|
||||
showAssistantCopyPartID?: string | null
|
||||
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])
|
||||
}
|
||||
|
||||
type PartRef = {
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
type PartGroup =
|
||||
export type PartGroup =
|
||||
| {
|
||||
key: string
|
||||
type: "part"
|
||||
@@ -520,14 +521,14 @@ function sameGroup(a: PartGroup, b: PartGroup) {
|
||||
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 false
|
||||
if (a.length !== b.length) return false
|
||||
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[] = []
|
||||
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))
|
||||
}
|
||||
|
||||
function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
export function renderable(part: PartType, showReasoningSummaries = true) {
|
||||
if (part.type === "tool") {
|
||||
if (HIDDEN_TOOLS.has(part.tool)) return false
|
||||
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
|
||||
}
|
||||
|
||||
function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
if (part.type !== "tool") return
|
||||
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 [open, setOpen] = createSignal(false)
|
||||
const pending = createMemo(
|
||||
@@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
|
||||
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>
|
||||
<div data-component="context-tool-group-trigger">
|
||||
<span
|
||||
@@ -1077,7 +1084,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message">
|
||||
<div data-component="user-message" data-timeline-part-id={textPart()?.id}>
|
||||
<Show when={attachments().length > 0}>
|
||||
<div data-slot="user-message-attachments">
|
||||
<For each={attachments()}>
|
||||
@@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
|
||||
message={props.message}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
deferToolContent={props.deferToolContent}
|
||||
showAssistantCopyPartID={props.showAssistantCopyPartID}
|
||||
turnDurationMs={props.turnDurationMs}
|
||||
/>
|
||||
@@ -1244,6 +1252,7 @@ export interface ToolProps {
|
||||
status?: string
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
deferContent?: boolean
|
||||
forceOpen?: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
@@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
|
||||
return (
|
||||
<Show when={!hideQuestion()}>
|
||||
<div data-component="tool-part-wrapper">
|
||||
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
|
||||
<Switch>
|
||||
<Match when={part().state.status === "error" && (part().state as any).error}>
|
||||
{(error) => {
|
||||
@@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
status={part().state.status}
|
||||
hideDetails={props.hideDetails}
|
||||
defaultOpen={props.defaultOpen}
|
||||
deferContent={props.deferToolContent}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
@@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
|
||||
return (
|
||||
<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} />}>
|
||||
<PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
|
||||
</Show>
|
||||
@@ -1913,7 +1923,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
@@ -1974,7 +1984,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
@@ -2056,7 +2066,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
defer={props.deferContent !== false}
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.patch"),
|
||||
subtitle: subtitle(),
|
||||
@@ -2128,7 +2138,7 @@ ToolRegistry.register({
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
@@ -2153,7 +2163,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="code-lines"
|
||||
defer
|
||||
defer={props.deferContent !== false}
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
|
||||
Reference in New Issue
Block a user