feat(app): add return sessions to the first unread message experiment (#61663)

This commit is contained in:
usrnk1
2026-11-04 15:03:26 +01:00
committed by GitHub
Unverified
parent 5a750e64a2
commit 8568952e77
18 changed files with 758 additions and 17 deletions
@@ -11,6 +11,7 @@ import {
sessionID,
setupTimeline,
status,
stepStarted,
textPart,
userMessage,
validateTimelineMessages,
@@ -21,6 +22,221 @@ import { openSession } from "../utils/workspace"
test.use({ serviceWorkers: "block" })
test.describe("timeline history", () => {
test("reopens at the first locally unseen reply", async ({ page }) => {
const notice = await openUnreadSession(page)
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
await expect(page.getByText("New paragraph 0.", { exact: true })).toBeInViewport()
})
test("reopens at the end when the new-messages divider is disabled", async ({ page }) => {
const notice = await openUnreadSession(page, { disabled: true })
await expect(page.getByText("New paragraph 59.", { exact: true })).toBeInViewport()
await expect(notice).toHaveCount(0)
})
test("reaching the end dismisses the new-messages divider without moving the reply", async ({ page }) => {
const notice = await openUnreadSession(page, { paragraphs: 5 })
const paragraph = page.getByText("New paragraph 4.", { exact: true })
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
await timelineScroller(page).evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await expect(paragraph).toBeInViewport()
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
const top = await paragraph.evaluate((element) => element.getBoundingClientRect().top)
await timelineScroller(page).dispatchEvent("wheel", { deltaY: 1 })
await expect(notice.getByText("New", { exact: true })).toBeHidden()
await expect.poll(() => paragraph.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top, 0)
})
test("returning to a cached session lands on work streamed while another tab was open", async ({ page }) => {
const other = "ses_new_other"
let history = [userMessage(), assistantMessage([textPart("prt_cached_seen", "Read before switching")])]
const workspace = await openSession(page, {
name: "NewMessagesCached",
sessions: [{ id: sessionID }, { id: other, directory: "C:/OpenCode/UnreadOther" }],
pageMessages: (id) => ({
items: id === other ? [userMessage([textPart("prt_other", "Other session")])] : history,
}),
})
await expect(page.getByText("Read before switching", { exact: true })).toBeVisible()
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(other)}"]`).click()
await expect(page.getByText("Other session", { exact: true })).toBeVisible()
const text = textPart(
"prt_cached_reply",
Array.from({ length: 40 }, (_, index) => `Cached reply ${index}.`).join("\n\n"),
)
const reply = assistantMessage([], { id: "msg_cached_new_reply", completed: false, created: 1700000002000 })
history = [
...history,
{ ...reply, content: [{ type: "text", text: text.text }], time: { ...reply.time, completed: 1700000003000 } },
]
await workspace.push([
status("busy"),
stepStarted(reply),
...partUpdated({
...text,
messageID: reply.id,
}),
messageUpdated(reply),
status("idle"),
status("busy", 1, other),
])
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(sessionID)}"]`)
// Wait for the stream to reach the active session before switching back.
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
await tab.click()
await expect(
page.locator('[data-slot="session-timeline-notice"]').getByText("New", { exact: true }),
).toBeInViewport()
await expect(page.getByText("Cached reply 0.", { exact: true })).toBeInViewport()
await page.getByRole("button", { name: "Jump to latest" }).click()
await expect(page.getByText("Cached reply 39.", { exact: true })).toBeInViewport()
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
})
test("marks new work above an undelivered steer", async ({ page }) => {
const id = "ses_unread_steer"
let messages = [
userMessage([textPart("prt_unread_prompt", "Read the code")], { id: "msg_1000_unread_prompt", created: 1 }),
assistantMessage([textPart("prt_unread_seen", "Read work")], {
id: "msg_1001_unread_work",
parentID: "msg_1000_unread_prompt",
created: 2,
completed: false,
}),
]
await openSession(page, {
name: "UnreadSteer",
sessions: [{ id }],
sessionStatus: { [id]: { type: "busy" } },
pageMessages: () => ({ items: messages }),
inbox: [
{
id: "msg_1002_unread_steer",
sessionID: id,
type: "user",
delivery: "steer",
payload: { text: "Waiting prompt" },
time: { created: 3 },
},
],
})
await expect(
page.locator('[data-timeline-row="UserMessage"]').getByText("Waiting prompt", { exact: true }),
).toBeVisible()
await expect(page.getByText("Read work", { exact: true })).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
messages = [
messages[0],
assistantMessage(
[
textPart("prt_unread_seen", "Read work"),
textPart("prt_unread_new", Array.from({ length: 60 }, (_, index) => `Unseen work ${index}.`).join("\n\n")),
],
{ id: "msg_1001_unread_work", parentID: "msg_1000_unread_prompt", created: 2, completed: false },
),
]
await page.reload()
await expect(page.locator('[data-timeline-row="NewMessages"]').getByText("New", { exact: true })).toBeInViewport()
await expect(page.getByText("Unseen work 0.", { exact: true })).toBeInViewport()
})
test("lands at the true unread boundary when older unread history loads", async ({ page }) => {
const id = "ses_unread_pages"
let messages: SessionMessageInfo[] = [
userMessage([textPart("prt_unread_pages_prompt", "Keep working")], { id: "msg_0000_unread_pages", created: 1 }),
assistantMessage([textPart("prt_unread_pages_seen", "Read before leaving")], {
id: "msg_0001_unread_pages",
parentID: "msg_0000_unread_pages",
created: 2,
}),
]
await openSession(page, {
name: "UnreadPages",
sessions: [{ id }],
pageMessages: (_id, _limit, before) => {
const end = before ? Number(before) : messages.length
const start = Math.max(0, end - 20)
return { items: messages.slice(start, end), cursor: start ? String(start) : undefined }
},
})
await expect(page.getByText("Read before leaving", { exact: true })).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
// The unread span exceeds the initial page and three enrichment pages.
messages = [
...messages,
...Array.from({ length: 90 }, (_, index) =>
assistantMessage([textPart(`prt_unread_page_${index}`, `Unread response ${index}`)], {
id: `msg_${String(index + 2).padStart(4, "0")}_unread_pages`,
parentID: "msg_0000_unread_pages",
created: index + 3,
}),
),
]
await page.reload()
const notice = page.locator('[data-timeline-key="new-messages:msg_0002_unread_pages:text:0"]')
await expect(notice).toBeInViewport()
await expect(page.getByText("Unread response 0", { exact: true })).toBeInViewport()
})
test("keeps unread replies before a prompt sent from another client", async ({ page }) => {
const id = "ses_unread_remote"
let messages = [
userMessage([textPart("prt_remote_prompt", "Start working")], { id: "msg_remote_0", created: 1 }),
assistantMessage([textPart("prt_remote_seen", "Already read")], { id: "msg_remote_1", created: 2 }),
]
const view = await openSession(page, {
name: "UnreadRemote",
sessions: [{ id }],
pageMessages: () => ({ items: messages }),
})
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
messages = [
...messages,
assistantMessage([textPart("prt_remote_unread", "Unread before the other client's prompt")], {
id: "msg_remote_2",
created: 3,
}),
userMessage([textPart("prt_remote_other", "Sent from another client")], { id: "msg_remote_3", created: 4 }),
assistantMessage(
[textPart("prt_remote_reply", Array.from({ length: 40 }, (_, index) => `Remote reply ${index}.`).join("\n\n"))],
{ id: "msg_remote_4", created: 5 },
),
]
await page.reload()
await expect(page.locator('[data-timeline-key="new-messages:msg_remote_2:text:0"]')).toBeInViewport()
await expect(page.getByText("Unread before the other client's prompt", { exact: true })).toBeInViewport()
await view.editor.fill("Continue from this client")
await view.editor.press("Enter")
await expect(page.getByText("Remote reply 39.", { exact: true })).toBeInViewport()
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
})
test("restoring a paged transcript waits for another upward scroll before loading history", async ({ page }) => {
const id = "ses_history_restore"
const other = "ses_history_other"
@@ -510,6 +726,40 @@ declare global {
}
}
async function openUnreadSession(page: Page, input: { disabled?: boolean; paragraphs?: number } = {}) {
const id = "ses_new_messages"
let messages = [
userMessage([textPart("prt_new_prompt", "Explain the implementation")], { id: "msg_new_prompt", created: 1 }),
assistantMessage([textPart("prt_seen", "Already read")], { id: "msg_seen", created: 2 }),
]
await openSession(page, {
name: "NewMessages",
sessions: [{ id }],
pageMessages: () => ({ items: messages }),
seed: input.disabled ? { settings: { general: { newMessagesDivider: false } } } : undefined,
})
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
messages = [
...messages,
assistantMessage(
[
textPart(
"prt_new_reply",
Array.from({ length: input.paragraphs ?? 60 }, (_, index) => `New paragraph ${index}.`).join("\n\n"),
),
],
{ id: "msg_new_reply", created: 3 },
),
]
await page.reload()
await expectSessionTitle(page, id)
return page.locator('[data-timeline-row="NewMessages"]')
}
async function openStressTarget(page: Page, input: Parameters<typeof mockStressTimeline>[1] = {}) {
await mockStressTimeline(page, input)
await installTimelineSettings(page)
+3
View File
@@ -982,6 +982,9 @@ export const dict = {
"settings.timeline.custom": "Custom",
"settings.timeline.row.turnDiffs.title": "Changed files",
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
"settings.timeline.row.newMessagesDivider.title": "New messages divider",
"settings.timeline.row.newMessagesDivider.description":
"When you return to a session, jump to the first unread message.",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.group": "Group",
+1
View File
@@ -300,6 +300,7 @@ function SessionScreenContent(props: {
<PluginLinks session={own()} active={active()}>
<MessageTimeline
active={active()}
caughtUp={timeline.resource.state === "ready" && timeline.resource.latest === source.identity.sessionID()}
hideHeader={!isDesktop()}
registerHeaderGap={registerHeaderGap}
session={source}
@@ -24,6 +24,7 @@ import { createTimelineProjection } from "./projection"
import { useServer } from "@/runtime/server/current"
import { getSessionMessageHandoff } from "@/session/handoff"
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
import { createNewMessages } from "./new-messages"
const emptyMessages: SessionMessageInfo[] = []
@@ -56,7 +57,11 @@ export type TimelineSessionSource = {
history: Pick<SessionModel["history"], "messages">
}
export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor<boolean> }) {
export function createTimelineController(input: {
session: TimelineSessionSource
active: Accessor<boolean>
caughtUp: Accessor<boolean>
}) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -181,6 +186,17 @@ export function createTimelineController(input: { session: TimelineSessionSource
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
const newMessages = createNewMessages({
scope: serverSDK.scope,
directory: sdk().directory,
sessionID: input.session.identity.sessionID()!,
messages: projectedMessages,
active: input.active,
enabled: settings.general.newMessagesDivider,
caughtUp: input.caughtUp,
pendingInputIDs,
})
const projection = createTimelineProjection({
sessionMessages: projectedMessages,
status: input.session.data.status,
@@ -191,6 +207,8 @@ export function createTimelineController(input: { session: TimelineSessionSource
pendingInputIDs,
queuedCompactionIDs,
turnDiffs: settings.general.showTurnDiffs,
newMessages: newMessages.parts,
newMessagesRead: newMessages.dismissed,
})
const [pending, setPending] = createStore({ rename: false })
@@ -340,6 +358,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
childTitle,
showHeader,
projection,
newMessages,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
@@ -64,7 +64,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
let revealPart = (_target: string) => false
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
let scrollToEnd = (_behavior?: ScrollBehavior, _read?: boolean) => {}
let page = (_direction: 1 | -1) => {}
@@ -186,7 +186,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
resumeFrame?.()
resumeFrame = frameLoop.add(() => {
resumeFrame = undefined
scrollToEnd(behavior)
scrollToEnd(behavior, true)
return false
})
@@ -441,7 +441,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
},
},
setScrollRef,
setScrollToEnd: (scroll: (behavior?: ScrollBehavior) => void) => {
setScrollToEnd: (scroll: (behavior?: ScrollBehavior, read?: boolean) => void) => {
scrollToEnd = scroll
},
setPage: (scroll: (direction: 1 | -1) => void) => {
@@ -74,6 +74,7 @@ type MessageTimelineProps = {
/** Hands the header's empty gap to the pane's window drag area. */
registerHeaderGap?: (gap: HTMLElement) => () => void
active?: boolean
caughtUp: boolean
session: TimelineSessionSource
view: MountedSession
screen: SessionScreen
@@ -97,14 +98,19 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
setPage?: (fn: (direction: 1 | -1) => void) => void
reveal?: { target: () => string | undefined; done: () => void }
search?: JSX.Element
}
export function MessageTimeline(props: MessageTimelineProps) {
const controller = createTimelineController({ session: props.session, active: () => props.active !== false })
const controller = createTimelineController({
session: props.session,
active: () => props.active !== false,
caughtUp: () => props.caughtUp,
})
const tail = props.pinned ? controller.data.projection.rows().at(-1) : undefined
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
@@ -190,6 +196,8 @@ function MessageTimelineView(
projection,
showHeader,
pinned,
onReadEnd: props.data.newMessages.read,
onReadMarkPassed: props.data.newMessages.release,
scroll: () => props.scroll,
onResumeScroll: props.onResumeScroll,
setScrollRef: props.setScrollRef,
@@ -206,6 +214,7 @@ function MessageTimelineView(
if (
Predicate.isTagged(row, "TurnGap") ||
Predicate.isTagged(row, "TurnDivider") ||
Predicate.isTagged(row, "NewMessages") ||
Predicate.isTagged(row, "CompactionQueued") ||
Predicate.isTagged(row, "Activity")
)
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode/client/promise"
import { unseenTimelineParts } from "./new-messages"
const messages = [
{ id: "msg_user", type: "user", text: "Prompt", time: { created: 1 } },
{
id: "msg_answer",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
time: { created: 2 },
content: [
{ type: "text", text: "Seen" },
{ type: "text", text: "New" },
],
},
] satisfies SessionMessageInfo[]
describe("unseenTimelineParts", () => {
test("includes parts appended to an existing assistant message, not its already seen parts", () => {
expect([
...unseenTimelineParts(messages, { messageID: "msg_answer", created: 2, partID: "msg_answer:text:0" }),
]).toEqual(["msg_answer:text:1"])
})
test("uses identity rather than timestamps when messages share a millisecond", () => {
expect([
...unseenTimelineParts(
messages.map((message) => ({ ...message, time: { created: 2 } })),
{ messageID: "msg_user", created: 2 },
),
]).toEqual(["msg_answer:text:0", "msg_answer:text:1"])
})
test("does not mark older parts when the saved part is absent from a refreshed message", () => {
expect([
...unseenTimelineParts(messages, { messageID: "msg_answer", created: 2, partID: "msg_answer:text:2" }),
]).toEqual([])
})
test("ignores earlier paged history when the cursor is outside the loaded window", () => {
expect([...unseenTimelineParts(messages, { messageID: "msg_evicted", created: 3 })]).toEqual([])
expect([...unseenTimelineParts(messages.slice(1), { messageID: "msg_evicted", created: 1 })]).toEqual([
"msg_answer:text:0",
"msg_answer:text:1",
])
})
})
@@ -0,0 +1,138 @@
import type { SessionMessageInfo } from "@opencode/client/promise"
import { Timeline } from "@opencode/session-ui/timeline/projection"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createComputed, createMemo, on, untrack, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { ServerScope } from "@/runtime/server/scope"
const cursorSchema = Schema.Struct({
messageID: Schema.String,
created: Schema.Number,
partID: Schema.optional(Schema.String),
})
type Cursor = typeof cursorSchema.Type
const emptyParts: ReadonlySet<string> = new Set()
export function unseenTimelineParts(messages: SessionMessageInfo[], cursor: Cursor | null) {
if (!cursor) return emptyParts
const seen = messages.findIndex((message) => message.id === cursor.messageID)
return new Set(
messages.flatMap((message, index) => {
if (message.type !== "assistant" || index < seen) return []
const parts = Timeline.contentEntries(message)
if (seen < 0) return message.time.created > cursor.created ? parts.map((part) => part.id) : []
if (index > seen) return parts.map((part) => part.id)
const part = parts.findIndex((part) => part.id === cursor.partID)
if (cursor.partID && part < 0) return []
return parts.slice(part + 1).map((part) => part.id)
}),
)
}
export function createNewMessages(input: {
scope: ServerScope
directory: string
sessionID: string
messages: Accessor<SessionMessageInfo[]>
active: Accessor<boolean>
enabled: Accessor<boolean>
caughtUp: Accessor<boolean>
pendingInputIDs: Accessor<ReadonlySet<string>>
}) {
const [saved, setSaved, , ready] = persisted(
Persist.serverSession(input.scope, input.directory, input.sessionID, "timeline-seen"),
Persistence.struct({ cursor: Schema.NullOr(cursorSchema) }),
{ cursor: null },
)
const [state, setState] = createStore<{
focused: boolean
phase: "idle" | "unread" | "read"
retained: ReadonlySet<string>
baseline: Cursor | null
}>({
focused: document.hasFocus() && document.visibilityState !== "hidden",
phase: "idle",
retained: emptyParts,
baseline: null,
})
const focus = () => setState("focused", document.hasFocus() && document.visibilityState !== "hidden")
makeEventListener(window, "focus", focus)
makeEventListener(window, "blur", focus)
makeEventListener(document, "visibilitychange", focus)
// Cached rows can render before the selected transcript finishes refreshing.
const watching = createMemo(() => input.active() && state.focused && ready() && input.caughtUp())
// Pending steers are displayed after live work, not at its read boundary.
const messages = createMemo(() => {
const pending = input.pendingInputIDs()
return pending.size ? input.messages().filter((message) => !pending.has(message.id)) : input.messages()
})
const unseen = createMemo(() =>
watching() && state.phase === "unread" ? unseenTimelineParts(messages(), state.baseline) : emptyParts,
)
createComputed(() => {
const parts = unseen()
if (parts.size) setState("retained", parts)
})
const read = () => {
if (!watching()) return
const message = messages().at(-1)
if (!message) return
const partID = message.type === "assistant" ? Timeline.contentEntries(message).at(-1)?.id : undefined
setState("phase", "read")
if (saved.cursor?.messageID !== message.id || saved.cursor?.partID !== partID)
setSaved("cursor", { messageID: message.id, created: message.time.created, partID })
}
createComputed(
on(watching, (watching) => {
if (!watching) return
setState({
baseline: saved.cursor ? { ...saved.cursor } : null,
phase: saved.cursor ? "unread" : "idle",
retained: emptyParts,
})
if (unseen().size === 0) read()
}),
)
createComputed(() => {
if (watching() && !input.enabled()) untrack(read)
})
return {
parts: createMemo(() => {
if (!watching()) return emptyParts
if (state.phase === "read") return state.retained
return input.enabled() ? unseen() : emptyParts
}),
dismissed: () => state.phase === "read",
read,
release: () => setState("retained", emptyParts),
}
}
@@ -82,6 +82,8 @@ type Input = {
setVisibleRef?: (element: HTMLDivElement | undefined) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onPin: () => void
onReadEnd?: () => void
onReadMarkPassed?: () => void
onUnpin: () => void
onSelectionInteraction: (event: MouseEvent) => void
onUserScroll: (target?: EventTarget | null) => void
@@ -92,7 +94,7 @@ type Input = {
) => boolean
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
/** Pages the timeline for keys pressed outside it, such as PageUp in the composer. */
setPage?: (fn: (direction: 1 | -1) => void) => void
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
@@ -133,8 +135,8 @@ export function createTimelineVirtualizer(input: Input) {
const [rendering, setRendering] = createStore<{
initialTail: boolean
scrollAdjustment: number
/** A revealed tool whose top edge stays put until the user scrolls. */
anchor?: { key: string; partID: string }
/** A revealed tool or unread notice whose top edge stays put until the user scrolls. */
anchor?: { key: string; partID?: string }
}>({ initialTail: coldBottomMount, scrollAdjustment: 0 })
const rows = input.projection.rows
@@ -228,6 +230,70 @@ export function createTimelineVirtualizer(input: Input) {
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
let landedNewMessages: string | undefined
let readIntent = false
const newMessagesRow = createMemo(() => rows().find((row) => Predicate.isTagged(row, "NewMessages")))
const newMessagesKey = createMemo(() => {
const row = newMessagesRow()
return row && !row.read ? TimelineRow.key(row) : undefined
})
const releaseReadMark = () => {
const row = newMessagesRow()
const root = listRoot()
if (!active() || !row?.read || !root?.isConnected) return
const item = virtualizer.measurementsCache[rowKeys().indexOf(TimelineRow.key(row))]
// Keep a dismissed notice's height until it leaves the reading area.
if (
item &&
(item.end <= root.scrollTop + virtualizer.options.scrollPaddingStart ||
item.start >= root.scrollTop + root.clientHeight - bottomInset())
)
input.onReadMarkPassed?.()
}
const readEnd = () => {
const root = listRoot()
if (!active() || coldPending || !root || (newMessagesKey() && !readIntent) || !atReachableEnd(root)) return
input.onReadEnd?.()
releaseReadMark()
}
const landNewMessages = () => {
const key = newMessagesKey()
const root = listRoot()
if (coldPending || !key || key === landedNewMessages || !root?.isConnected) return false
const anchor = rendering.anchor
if ((!input.pinned() && (!anchor || anchor.partID)) || location.hash || input.reveal?.target()) {
landedNewMessages = key
return false
}
// unpin() ignores non-overflowing lists; wait for the new reply's measured height.
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (root.scrollHeight - root.clientHeight <= 1) return false
landedNewMessages = key
readIntent = false
prepareNavigation()
input.onUnpin()
setRendering("anchor", { key })
virtualizer.scrollToIndex(rowKeys().indexOf(key), { align: "start" })
return true
}
const [headerHeight, setHeaderHeight] = createSignal(48)
@@ -283,6 +349,9 @@ export function createTimelineVirtualizer(input: Input) {
if (root && !input.pinned() && !rendering.anchor && atReachableEnd(root)) input.onPin()
})
settleColdBottom()
if (!scrolling) readEnd()
releaseReadMark()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
@@ -410,11 +479,14 @@ export function createTimelineVirtualizer(input: Input) {
if (coldPending) return
landNewMessages()
if (!input.pinned()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && !atReachableEnd(root)) virtualizer.scrollToEnd()
readEnd()
})
}
@@ -491,14 +563,19 @@ export function createTimelineVirtualizer(input: Input) {
return true
}
// Puts the anchored tool's top edge at its scroll margin, just below the headers that stick above it. Its own
// Puts the anchored row's top edge at its scroll margin, just below the headers that stick above it. Its own
// growth extends downward, so only size changes at or above its row can move it; each of those calls this again.
function pinAnchor() {
const anchor = rendering.anchor
const root = listRoot()
if (!anchor || !root) return
const element = virtualContent?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`)
const element = virtualContent?.querySelector<HTMLElement>(
anchor.partID
? `[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`
: `[data-timeline-key="${CSS.escape(anchor.key)}"]`,
)
// Following the end is a different position to hold.
if (!element || !active() || input.pinned()) return releaseAnchor()
@@ -510,7 +587,9 @@ export function createTimelineVirtualizer(input: Input) {
root.scrollTop +
element.getBoundingClientRect().top -
root.getBoundingClientRect().top -
parseFloat(getComputedStyle(element).scrollMarginTop),
(anchor.partID
? parseFloat(getComputedStyle(element).scrollMarginTop)
: virtualizer.options.scrollPaddingStart),
),
)
@@ -551,7 +630,12 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) return
if (!active()) {
landedNewMessages = undefined
return
}
const root = listRoot()
if (root) input.setScrollRef(root)
@@ -566,10 +650,11 @@ export function createTimelineVirtualizer(input: Input) {
const target = input.reveal?.target()
if (target && revealPart(target)) input.reveal?.done()
else if (input.pinned()) virtualizer.scrollToEnd()
else if (!landNewMessages() && input.pinned()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
settleColdBottom()
readEnd()
})
input.setRevealPart?.(revealPart)
input.setRevealMessage?.((id, partID) => {
@@ -591,10 +676,20 @@ export function createTimelineVirtualizer(input: Input) {
prepareNavigation()
virtualizer.scrollToIndex(index, { align: partIndex >= 0 ? "center" : "start" })
})
input.setScrollToEnd?.((behavior) => {
input.setScrollToEnd?.((behavior, read) => {
const root = listRoot()
if (!active() || !root?.isConnected) return
if (read) readIntent = true
// Composer measurement also requests the end. It must not override an unread arrival.
if (newMessagesKey() && !readIntent && !location.hash && !input.reveal?.target()) {
landNewMessages()
return
}
prepareNavigation()
input.onPin()
@@ -602,6 +697,7 @@ export function createTimelineVirtualizer(input: Input) {
if (behavior === "smooth")
virtualizer.scrollToOffset(Math.max(root.scrollTop, root.scrollHeight - root.clientHeight * 2))
virtualizer.scrollToEnd({ behavior })
readEnd()
})
input.setPage?.((direction) => {
const root = listRoot()
@@ -611,12 +707,28 @@ export function createTimelineVirtualizer(input: Input) {
input.onUserScroll(root)
if (direction < 0) input.onUnpin()
readIntent = true
// Like the timeline's own PageUp: 80% of a page, measured above the composer that covers its bottom. The scroll
// event then pins at the end or loads older history near the top, as a wheel scroll does.
virtualizer.scrollToOffset(root.scrollTop + direction * (visibleArea()?.clientHeight || root.clientHeight) * 0.8)
})
})
createEffect(() => {
const key = newMessagesKey()
const anchor = rendering.anchor
if (anchor && !anchor.partID && anchor.key !== key) setRendering("anchor", key ? { key } : undefined)
if (active())
queueMicrotask(() => {
if (!active()) return
landNewMessages()
releaseReadMark()
})
})
createEffect(
on(
bottomInset,
@@ -739,7 +851,9 @@ export function createTimelineVirtualizer(input: Input) {
coldPending = false
contentObserver?.disconnect()
viewportObserver?.disconnect()
landNewMessages()
virtualContent.style.removeProperty("visibility")
readEnd()
})
}
@@ -792,13 +906,17 @@ export function createTimelineVirtualizer(input: Input) {
// Upward input is the one intent geometry cannot recover: nudging up while still a pixel from
// the end must stop following, even though the resulting position still looks like the end.
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
readIntent = true
releaseAnchor()
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
if (event.deltaY > 0) readEnd()
}
const handleListTouchStart = (event: TouchEvent) => {
readIntent = true
clearTouchTarget()
releaseAnchor()
input.onUserScroll(event.target)
@@ -857,6 +975,7 @@ export function createTimelineVirtualizer(input: Input) {
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
const handleListPointerDown = (event: PointerEvent) => {
readIntent = true
releaseAnchor()
input.onUserScroll(event.target)
pointerHeld = true
@@ -883,6 +1002,7 @@ export function createTimelineVirtualizer(input: Input) {
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
readIntent = true
releaseAnchor()
input.onUserScroll(event.currentTarget)
@@ -64,6 +64,20 @@ export const SettingsExperimental: Component = () => {
</Switch>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.timeline.row.newMessagesDivider.title")}
description={language.t("settings.timeline.row.newMessagesDivider.description")}
>
<div data-action="settings-new-messages-divider">
<Switch
checked={settings.general.newMessagesDivider()}
onChange={settings.general.setNewMessagesDivider}
hideLabel
>
{language.t("settings.timeline.row.newMessagesDivider.title")}
</Switch>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
+1
View File
@@ -93,6 +93,7 @@ describe("settings schema", () => {
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
showTurnDiffs: false,
newMessagesDivider: true,
backgroundActivity: "left",
timelineWidth: "comfortable",
newIcons: false,
+9
View File
@@ -125,6 +125,7 @@ const generalSchema = Persistence.struct({
}),
showCustomAgents: Schema.Boolean,
showTurnDiffs: Schema.Boolean,
newMessagesDivider: Schema.Boolean,
backgroundActivity: Schema.Literals(["off", "left", "center", "right"]),
timelineWidth: Schema.Literals(["focused", "comfortable", "wide"]),
// The "New icons" design experiment, off until it ships.
@@ -301,6 +302,7 @@ export const defaultSettings: Settings = {
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
showTurnDiffs: false,
newMessagesDivider: true,
backgroundActivity: "left",
timelineWidth: "comfortable",
newIcons: false,
@@ -401,6 +403,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTurnDiffs(value: boolean) {
setStore("general", "showTurnDiffs", value)
},
newMessagesDivider: withFallback(
() => store.general?.newMessagesDivider,
defaultSettings.general.newMessagesDivider,
),
setNewMessagesDivider(value: boolean) {
setStore("general", "newMessagesDivider", value)
},
backgroundActivity: withFallback(
() => store.general?.backgroundActivity,
defaultSettings.general.backgroundActivity,
@@ -125,6 +125,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
description: "settings.experimental.row.newQuestionUI.description",
keywords: "question answers keyboard dock redesign",
},
{
tab: "experimental",
label: "settings.timeline.row.newMessagesDivider.title",
description: "settings.timeline.row.newMessagesDivider.description",
target: "settings-new-messages-divider",
keywords: "unread new messages divider return session",
},
{
tab: "appearance",
label: "settings.general.row.colorScheme.title",
@@ -6,6 +6,7 @@ import type {
SessionMessageInfo,
} from "@opencode/client/promise"
import { createStore } from "solid-js/store"
import { Predicate } from "effect"
import {
createTimelineProjection,
reasoningHeading,
@@ -254,6 +255,59 @@ describe("reuseTimelineRows", () => {
})
describe("createTimelineProjection", () => {
test("keeps new tools folded into seen work above the new-messages notice", () => {
const messages = [
{ id: "user", type: "user", text: "Read files", time: { created: 1 } },
{
id: "assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
time: { created: 2, completed: 3 },
content: [
...["seen", "new"].map(
(id): SessionMessageAssistantTool => ({
type: "tool",
id,
name: "read",
state: {
status: "completed",
input: { filePath: `${id}.ts` },
content: [{ type: "text", text: id }],
metadata: {},
},
time: { created: 2, completed: 3 },
}),
),
{ type: "text", text: "The result" },
],
},
] satisfies SessionMessageInfo[]
const folded = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
reasoningMode: "full",
newMessages: new Set(["new"]),
})
expect(folded.rows.some((row) => Predicate.isTagged(row, "NewMessages"))).toBe(false)
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
reasoningMode: "full",
newMessages: new Set(["new", "assistant:text:0"]),
})
const index = result.rows.findIndex((row) => Predicate.isTagged(row, "NewMessages"))
expect(result.rows[index]).toEqual(
new TimelineRow.NewMessages({ userMessageID: "user", partID: "assistant:text:0", read: false }),
)
expect(result.rows[index + 1]).toEqual(folded.rows[index])
})
test("builds current message, parent, context, and row indexes", () => {
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
+34 -2
View File
@@ -82,6 +82,8 @@ export type TimelineProjectionInput = {
queuedCompactionIDs?: readonly string[]
turnDiffs?: boolean
previousRows?: TimelineRow.TimelineRow[]
newMessages?: ReadonlySet<string>
newMessagesRead?: boolean
}
export function createTimelineProjection(input: TimelineProjectionInput) {
@@ -100,7 +102,11 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
input.turnDiffs,
)
const rows = reuseTimelineRows(input.previousRows, projection.rows)
const rows = reuseTimelineRows(
input.previousRows,
withNewMessages(projection.rows, input.newMessages, input.newMessagesRead),
)
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
const messageRowIndex = new Map<string, number>()
const messageLastRowIndex = new Map<string, number>()
@@ -139,6 +145,8 @@ export function createReactiveTimelineProjection(input: {
queuedCompactionIDs?: Accessor<readonly string[]>
/** Summarize each finished turn's changed files after it. */
turnDiffs?: Accessor<boolean>
newMessages?: Accessor<ReadonlySet<string>>
newMessagesRead?: Accessor<boolean>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
@@ -192,7 +200,7 @@ export function createReactiveTimelineProjection(input: {
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
reuseTimelineRows(previous, withNewMessages(projection().rows, input.newMessages?.(), input.newMessagesRead?.())),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
@@ -238,6 +246,30 @@ export function createReactiveTimelineProjection(input: {
}
}
function withNewMessages(rows: TimelineRow.TimelineRow[], parts?: ReadonlySet<string>, read = false) {
if (!parts?.size) return rows
for (const [index, row] of rows.entries()) {
if (!Predicate.isTagged(row, "Thinking") && !Predicate.isTagged(row, "AssistantPart")) continue
const first = Predicate.isTagged(row, "Thinking")
? row.ref
: row.group.type === "part"
? row.group.ref
: row.group.refs[0]
if (!first || !parts.has(first.partID)) continue
return [
...rows.slice(0, index),
new TimelineRow.NewMessages({ userMessageID: row.userMessageID, partID: first.partID, read }),
...rows.slice(index),
]
}
return rows
}
export namespace Timeline {
export function constructSessionMessageRows(
messages: SessionMessageInfo[],
@@ -1037,6 +1037,29 @@ export function createSessionTimelineRowRenderer(input: {
)
}
if (Predicate.isTagged(row(), "NewMessages")) {
const current = () => {
const value = row()
if (!Predicate.isTagged(value, "NewMessages")) throw new Error("Expected a new-messages timeline row")
return value
}
return (
<Frame row={current()}>
<div
data-slot="session-timeline-notice"
aria-hidden={current().read}
class={`w-full py-2 ${padding()}`}
classList={{ invisible: current().read }}
>
<TimelineSeparator label={i18n.t("ui.sessionTimeline.notice.newMessages")} />
</div>
</Frame>
)
}
if (Predicate.isTagged(row(), "TurnDivider")) {
const current = () => {
const value = row()
@@ -45,6 +45,12 @@ export namespace TimelineRow {
userMessageID: string
}> {}
export class NewMessages extends Data.TaggedClass("NewMessages")<{
userMessageID: string
partID: string
read: boolean
}> {}
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
userMessageID: string
group: PartGroup
@@ -104,6 +110,7 @@ export namespace TimelineRow {
| Shell
| Notice
| TurnDivider
| NewMessages
| AssistantPart
| Thinking
| Error
@@ -118,6 +125,7 @@ export namespace TimelineRow {
Shell: (row) => `shell:${row.messageID}`,
Notice: (row) => `notice:${row.messageID}`,
TurnDivider: (row) => `turn-divider:${row.userMessageID}`,
NewMessages: (row) => `new-messages:${row.partID}`,
// Keyed by part identity alone: a page boundary can truncate the leading turn,
// and its rows regroup under the real user message once older history loads.
// The group key already carries the owning message and part IDs.
@@ -140,6 +148,8 @@ export namespace TimelineRow {
Shell: (row) => (other: TimelineRow) => Predicate.isTagged(other, "Shell") && other.messageID === row.messageID,
Notice: (row) => (other: TimelineRow) => Predicate.isTagged(other, "Notice") && other.messageID === row.messageID,
TurnDivider: () => (other: TimelineRow) => Predicate.isTagged(other, "TurnDivider"),
NewMessages: (row) => (other: TimelineRow) =>
Predicate.isTagged(other, "NewMessages") && other.partID === row.partID && other.read === row.read,
AssistantPart: (row) => (other: TimelineRow) =>
Predicate.isTagged(other, "AssistantPart") &&
other.previousAssistantPart === row.previousAssistantPart &&
@@ -194,6 +204,7 @@ export type TimelineRowMap = {
Shell: { userMessageID: string; messageID: string }
Notice: { userMessageID: string; messageID: string }
TurnDivider: { userMessageID: string }
NewMessages: { userMessageID: string; partID: string; read: boolean }
AssistantPart: {
userMessageID: string
group: PartGroup
+1
View File
@@ -312,6 +312,7 @@ const source = {
"ui.sources.openFailed": "Couldn’t open page",
"ui.sessionTimeline.notice.model": "Model",
"ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}",
"ui.sessionTimeline.notice.newMessages": "New",
"ui.sessionTimeline.notice.variantSwitched": "Switched variant to {{variant}}",
"ui.sessionTimeline.notice.variantDefault": "Switched variant to Default",
"ui.sessionTimeline.notice.agentChanged": "Agent changed",