mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 10:06:09 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68e3888bc3 | ||
|
|
27a53969d6 | ||
|
|
1e3d3fcaca | ||
|
|
7420903859 |
+4
-4
@@ -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 }) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user