Compare commits

...
17 changed files with 361 additions and 43 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
"x86_64-linux": "sha256-2+fEzSA/1LPv/Hw7TP1Gu8boByTqoKyAvS1TKX2vLpo=",
"aarch64-linux": "sha256-+xOGlHZjkaddY91AOxq7xyUDFFKFWwaYGJOZQkV/Rtk=",
"aarch64-darwin": "sha256-HS9mG3OBsCBw2CjIQ4OE6fHOZ07gt4B1bS3YJrXG7Mw=",
"x86_64-darwin": "sha256-+gZT6aNbQRWPI7+hEhNOoE+/6uuIEUcEOVndtuFs7Fs="
}
}
@@ -98,7 +98,7 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="tool-trigger"]')
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
})
@@ -111,7 +111,9 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
await expect(
group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'),
).toHaveText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
+10 -1
View File
@@ -16,6 +16,7 @@ import { useTabs } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame } from "./session-frame"
import { SessionIdentityHeader } from "./session-identity-header"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
@@ -83,7 +84,7 @@ function ResolvedTargetSessionRoute() {
</SessionStatePanel>
}
>
<Show when={directory()} fallback={<SessionStatePanel />}>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
@@ -96,6 +97,14 @@ function ResolvedTargetSessionRoute() {
)
}
function PendingSessionState(props: { sessionID: string }) {
return (
<SessionStatePanel>
<SessionIdentityHeader sessionID={props.sessionID} />
</SessionStatePanel>
)
}
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 p-2">
+11 -1
View File
@@ -17,6 +17,7 @@ import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
@@ -73,7 +74,16 @@ export function SessionScreen(props: { session: SessionModel }) {
<SessionMobileReview review={review} />
</Match>
<Match when={session.identity.params.id}>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
<Show when={!messagesReady()}>
<SessionIdentityHeader
sessionID={session.identity.params.id ?? ""}
session={session.data.info()}
/>
</Show>
<Show
when={messagesReady() ? session.identity.params.id : undefined}
keyed
>
{(_id) => (
<MessageTimeline
session={session}
@@ -0,0 +1,88 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionTitle } from "./title"
export function SessionTitleHeader(props: ParentProps) {
return (
<div
data-session-title
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
>
{props.children}
</div>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const info = createMemo(
() => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })],
)
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title))
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
})
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
return (
<Show when={title() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center">
<span
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
}}
>
<Show
when={showProjectIcon()}
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
>
<ProjectAvatar
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
src={getProjectAvatarSource(project()?.id, project()?.icon)}
variant={getProjectAvatarVariant(project()?.icon?.color)}
/>
</Show>
</span>
<Show when={title()}>
{(value) => (
<h1
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
)}
</Show>
</div>
</div>
</div>
</SessionTitleHeader>
</Show>
)
}
@@ -1,7 +1,7 @@
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, createSignal, on, onCleanup } from "solid-js"
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLayout } from "@/shell/state/layout"
import type { SessionModel } from "../model"
@@ -19,6 +19,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
overflow: false,
jump: false,
},
follow: {
sessionKey: session.identity.sessionKey(),
pinned: true,
},
refs: {
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
@@ -26,11 +30,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
})
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const [pinned, setPinned] = createSignal(true)
const pin = () => setPinned(true)
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setPinned(false)
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
@@ -209,8 +213,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
on(
session.identity.sessionKey,
() => {
pin()
setState("messageID", undefined)
setState("pendingMessage", undefined)
setState("scroll", { overflow: false, jump: false })
},
{ defer: true },
),
@@ -30,6 +30,7 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
type BackgroundTask = {
id: string
@@ -523,10 +524,7 @@ function MessageTimelineView(
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
<div
data-session-title
class="sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] w-full pb-4 pr-3 pl-2.5"
>
<SessionTitleHeader>
<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">
<div class="flex items-center min-w-0 flex-1 w-full">
@@ -716,7 +714,7 @@ function MessageTimelineView(
)}
</Show>
</div>
</div>
</SessionTitleHeader>
}
/>
)
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
import {
enrichLeadingTurn,
leadingTurnNeedsParent,
loadOlderTimeline,
selectUserMessages,
selectVisibleUserMessages,
} from "./model"
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string): SessionMessageAssistant => ({
@@ -42,6 +48,56 @@ describe("timeline model", () => {
expect(anchors).toEqual(["before", "after", true])
})
test("recognizes a leading partial assistant turn", () => {
expect(leadingTurnNeedsParent([assistant("msg_assistant"), user("msg_next")])).toBe(true)
expect(leadingTurnNeedsParent([user("msg_user"), assistant("msg_assistant")])).toBe(false)
expect(leadingTurnNeedsParent([user("msg_user")])).toBe(false)
})
test("pauses between bounded history pages until the leading turn has its parent", async () => {
const pages: SessionMessageInfo[][] = [[assistant("msg_older")], [user("msg_parent")]]
const messages: SessionMessageInfo[] = [assistant("msg_latest"), user("msg_next")]
let pauses = 0
let loads = 0
await enrichLeadingTurn({
current: () => true,
messages: () => messages,
more: () => pages.length > 0,
loading: () => false,
loadMore: async () => {
messages.unshift(...pages.shift()!)
loads += 1
},
pause: async () => {
pauses += 1
},
maxPages: 3,
})
expect(loads).toBe(2)
expect(pauses).toBe(2)
expect(leadingTurnNeedsParent(messages)).toBe(false)
})
test("caps background pages when the parent remains outside the window", async () => {
let loads = 0
await enrichLeadingTurn({
current: () => true,
messages: () => [assistant("msg_latest")],
more: () => true,
loading: () => false,
loadMore: async () => {
loads += 1
},
pause: async () => undefined,
maxPages: 3,
})
expect(loads).toBe(3)
})
test("does not restore an anchor after the session changes", async () => {
let sessionID = "ses_old"
let restore = 0
+55 -3
View File
@@ -1,7 +1,11 @@
import { createMemo, createResource, type Accessor } from "solid-js"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { useData } from "@/runtime/server/current"
import type { SessionModel } from "../model"
const leadingTurnPageDelay = 200
const leadingTurnPageLimit = 3
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
@@ -9,12 +13,32 @@ export {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const prepared = new Set<string>()
const [resource] = createResource(
() => input.session.identity.sessionID(),
(id) => (id ? Promise.all([data.session.message.sync(id), data.session.pending.sync(id)]) : undefined),
async (id) => {
if (!id) return
const key = input.session.identity.sessionKey()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: () => input.session.identity.sessionKey() === key,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
loadMore: () => data.session.message.loadMore(id),
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
if (input.session.identity.sessionKey() === key) prepared.add(key)
},
)
const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
const messages = data.session.message.list(id)
return messages.length > 0 && !leadingTurnNeedsParent(messages)
})
const more = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.more(id) : false
@@ -28,7 +52,7 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
sessionID: input.session.identity.sessionID,
more,
loading,
loadMore: data.session.message.loadMore,
loadMore: (id) => data.session.message.loadMore(id),
before: options?.before,
after: options?.after,
})
@@ -44,6 +68,34 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
}
}
export async function enrichLeadingTurn(input: {
current: Accessor<boolean>
messages: Accessor<SessionMessageInfo[]>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: () => Promise<void>
pause: () => Promise<void>
maxPages: number
}) {
const load = async (pages: number): Promise<void> => {
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
return
await input.pause()
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
if (input.loading()) return load(pages)
await input.loadMore()
return load(pages + 1)
}
return load(0)
}
export function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
const assistant = messages.findIndex((message) => message.type === "assistant")
if (assistant === -1) return false
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
return boundary === -1 || assistant < boundary
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
@@ -63,7 +63,8 @@ export function createTimelineProjection(input: {
input.sessionMessages().forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
if (message.type !== "assistant") return
if (!userID) userID = message.id
const messages = result.get(userID)
if (messages) {
messages.push(message)
@@ -20,6 +20,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
@@ -177,10 +178,48 @@ export function createTimelineVirtualizer(input: Input) {
})
let overscanFrame: number | undefined
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
overscanFrame = requestAnimationFrame(settleColdBottom)
return
}
overscanFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
overscanFrame = undefined
const content = virtualContent
if (!content) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
content.style.removeProperty("visibility")
return
}
const animation = ["animate-in", "fade-in", "duration-150"]
const clearAnimation = (event: AnimationEvent) => {
if (event.target !== content) return
content.removeEventListener("animationend", clearAnimation)
content.removeEventListener("animationcancel", clearAnimation)
content.classList.remove(...animation)
}
content.addEventListener("animationend", clearAnimation)
content.addEventListener("animationcancel", clearAnimation)
content.classList.add(...animation)
content.style.removeProperty("visibility")
})
}
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (!coldBottomMount) {
overscanFrame = undefined
return
}
settleColdBottom()
})
})
@@ -367,11 +406,17 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
virtualContent = element
input.setContentRef(element)
}}
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
width: "100%",
visibility: coldBottomMount ? "hidden" : undefined,
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>
@@ -50,6 +50,7 @@ type RenderedBlock =
type RenderResult = {
text: string
blocks: RenderedBlock[]
ready: boolean
}
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
@@ -367,8 +368,14 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
}
}
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
if (!text) return { text, blocks: [] }
function initialResult(
text: string,
key: string | undefined,
projection: Projection,
owner: string,
deferUntilReady: boolean | undefined,
): RenderResult {
if (!text) return { text, blocks: [], ready: true }
const base = key ?? checksum(text)
if (base) {
const blocks = projection.blocks.flatMap((block, index) => {
@@ -378,10 +385,12 @@ function initialResult(text: string, key: string | undefined, projection: Projec
if (cached?.raw !== block.raw) return []
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
})
if (blocks.length === projection.blocks.length) return { text, blocks }
if (blocks.length === projection.blocks.length) return { text, blocks, ready: true }
}
if (deferUntilReady) return { text, blocks: [], ready: false }
return {
text,
ready: false,
blocks: [
{
key: "initial",
@@ -403,11 +412,12 @@ export function Markdown(
text: string
cacheKey?: string
streaming?: boolean
deferUntilReady?: boolean
class?: string
classList?: Record<string, boolean>
},
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
@@ -448,10 +458,11 @@ export function Markdown(
projection: value,
}
},
async (src) => {
async (src): Promise<RenderResult> => {
if (isServer)
return {
text: src.text,
ready: true,
blocks: [
{
key: "server",
@@ -462,7 +473,7 @@ export function Markdown(
},
],
} satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [], ready: true } satisfies RenderResult
const base = src.key ?? checksum(src.text)
return Promise.all(
@@ -500,11 +511,12 @@ export function Markdown(
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
}),
)
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
.then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult)
.catch(
() =>
({
text: src.text,
ready: true,
blocks: [
{
key: base ?? "fallback",
@@ -523,6 +535,7 @@ export function Markdown(
local.cacheKey,
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
owner,
local.deferUntilReady,
),
},
)
@@ -533,12 +546,14 @@ export function Markdown(
const container = root()
const result = html.latest ?? html()
const projected = currentProjection()
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : []
if (!container) return
if (isServer) return
delete container.dataset.markdownReady
if (content.length === 0) {
disposeCopyButtons(container)
container.innerHTML = ""
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
return
}
@@ -567,6 +582,7 @@ export function Markdown(
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}))
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
})
onCleanup(() => {
@@ -579,7 +595,6 @@ export function Markdown(
return (
<div
data-component="markdown"
data-markdown-ready={html.loading ? undefined : ""}
dir="auto"
classList={{
...local.classList,
@@ -596,9 +611,11 @@ function pendingBlocks(
projection: Projection | undefined,
cacheKey: string | undefined,
owner: string,
deferUntilReady: boolean | undefined,
) {
if (!result) return []
if (!projection || result.text === projection.text) return result.blocks
if (deferUntilReady) return result.blocks
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
return projection.blocks.map((block, index) => {
const current = initial ? undefined : result.blocks[index]
@@ -41,7 +41,7 @@ export function SessionAssistantContent(props: {
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
turnDurationMs?: number | null
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
@@ -159,7 +159,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole
return (
<Show when={value()}>
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} deferUntilReady />
</Show>
)
}
@@ -388,7 +388,7 @@ export function AssistantTextContent(props: {
text: string
message: SessionMessageAssistant
showCopy: boolean
turnDurationMs?: number
turnDurationMs?: number | null
}) {
const data = useData()
const i18n = useI18n()
@@ -404,11 +404,13 @@ export function AssistantTextContent(props: {
const duration = createMemo(() => {
const completed = props.message.time.completed
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - props.message.time.created
: -1
props.turnDurationMs === null
? -1
: typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - props.message.time.created
: -1
if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000)
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
@@ -196,4 +196,35 @@ describe("createTimelineProjection", () => {
expect(second.rows[1]).toBe(first.rows[1])
})
test("indexes a leading partial assistant turn under its projected turn ID", () => {
const messages = [
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "partial answer" }],
time: { created: 2, completed: 3 },
},
{
id: "assistant-2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "final answer" }],
time: { created: 4, completed: 5 },
},
] satisfies SessionMessageInfo[]
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
})
expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([
"assistant-1",
"assistant-2",
])
})
})
@@ -392,7 +392,8 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) {
messages.forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
if (message.type !== "assistant") return
if (!userID) userID = message.id
const existing = result.get(userID)
if (existing) {
existing.push(message)
@@ -55,7 +55,7 @@ export function createSessionTimelineRowRenderer(input: {
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
const duration = (messageID: string) => {
const user = input.projection.messageByID().get(messageID)
if (user?.type !== "user") return undefined
if (user?.type !== "user") return null
const completed = (input.projection.assistantMessagesByParent().get(messageID) ?? emptyAssistantMessages).reduce<
number | undefined
>((latest, message) => {