fix(app): remove the new messages divider (#61878)

This commit is contained in:
Luke Parker
2026-11-04 20:04:32 -06:00
committed by GitHub
Unverified
parent 14638b54d3
commit a4b754e740
21 changed files with 22 additions and 782 deletions
@@ -11,7 +11,6 @@ import {
sessionID,
setupTimeline,
status,
stepStarted,
textPart,
userMessage,
validateTimelineMessages,
@@ -22,221 +21,6 @@ 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"
@@ -726,40 +510,6 @@ 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
@@ -1008,9 +1008,6 @@ 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
@@ -301,7 +301,6 @@ 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}
@@ -81,7 +81,7 @@ describe("cold mount", () => {
const shown = run(exposed.state, ...check(1, "settled"))
expect(shown.state).toEqual({ type: "revealed" })
expect(shown.effects).toEqual([{ type: "release" }, { type: "reveal" }])
expect(shown.effects).toEqual([{ type: "release" }])
})
// Before the machine, a Markdown block that never set data-markdown-ready (or a view that stayed detached) kept
@@ -96,7 +96,7 @@ describe("cold mount", () => {
const next = transitionColdMount(stuck.state, { type: "timeout", attempt: 1 })
expect(next.state).toEqual({ type: "revealed" })
expect(next.effects).toEqual([{ type: "release" }, { type: "pin" }, { type: "reveal" }])
expect(next.effects).toEqual([{ type: "release" }, { type: "pin" }])
expect(transitionColdMount(next.state, CHECK_REQUESTED).state).toBe(next.state)
},
)
@@ -43,7 +43,6 @@ export type ColdMountEffect =
| { readonly type: "check.schedule"; readonly attempt: number }
| { readonly type: "check.sample"; readonly attempt: number }
| { readonly type: "pin" }
| { readonly type: "reveal" }
| { readonly type: "release" }
export type ColdMountTransition = {
@@ -130,7 +129,7 @@ export function transitionColdMount(state: ColdMountState, event: ColdMountEvent
if (event.type === "timeout") {
if (!current(state, event.attempt)) return quiet(state)
return { state: revealed, effects: [{ type: "release" }, { type: "pin" }, { type: "reveal" }] }
return { state: revealed, effects: [{ type: "release" }, { type: "pin" }] }
}
return { state: revealed, effects: [{ type: "release" }] }
@@ -168,7 +167,7 @@ function sampled(
if (event.sample === "pinned") return quiet({ ...state, checking: false })
return { state: revealed, effects: [{ type: "release" }, { type: "reveal" }] }
return { state: revealed, effects: [{ type: "release" }] }
}
function current(state: Exclude<ColdMountState, { type: "revealed" }>, attempt: number) {
@@ -24,7 +24,6 @@ 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[] = []
@@ -57,11 +56,7 @@ export type TimelineSessionSource = {
history: Pick<SessionModel["history"], "messages">
}
export function createTimelineController(input: {
session: TimelineSessionSource
active: Accessor<boolean>
caughtUp: Accessor<boolean>
}) {
export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor<boolean> }) {
const navigate = useNavigate()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
@@ -186,17 +181,6 @@ export function createTimelineController(input: {
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,
@@ -207,8 +191,6 @@ export function createTimelineController(input: {
pendingInputIDs,
queuedCompactionIDs,
turnDiffs: settings.general.showTurnDiffs,
newMessages: newMessages.parts,
newMessagesRead: newMessages.dismissed,
})
const [pending, setPending] = createStore({ rename: false })
@@ -358,7 +340,6 @@ export function createTimelineController(input: {
childTitle,
showHeader,
projection,
newMessages,
timelineDetail,
reasoningMode,
shellToolPartsExpanded,
@@ -55,7 +55,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
let revealPart = (_target: string) => false
let scrollToEnd = (_behavior?: ScrollBehavior, _read?: boolean) => {}
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
let page = (_direction: 1 | -1) => {}
@@ -181,7 +181,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
const before = follow.peek()
follow.dispatch({ type: "return.run", attempt })
if (follow.peek() !== before) scrollToEnd(behavior, true)
if (follow.peek() !== before) scrollToEnd(behavior)
return false
})
@@ -428,7 +428,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
},
},
setScrollRef,
setScrollToEnd: (scroll: (behavior?: ScrollBehavior, read?: boolean) => void) => {
setScrollToEnd: (scroll: (behavior?: ScrollBehavior) => void) => {
scrollToEnd = scroll
},
setPage: (scroll: (direction: 1 | -1) => void) => {
+3 -3
View File
@@ -9,8 +9,8 @@ export const ZOOM_SLACK = 4
// A scroll range at or below this does not overflow.
export const MIN_OVERFLOW = 1
/** A revealed tool call (`partID`) or the unread-messages notice, whose top edge stays put until the reader scrolls. */
export type FollowAnchor = { readonly key: string; readonly partID?: string }
/** A revealed tool call, whose top edge stays put until the reader scrolls. */
export type FollowAnchor = { readonly key: string; readonly partID: string }
/**
* The reader's contact with the timeline. A held pointer or touch is what separates the reader's own upward scroll
@@ -26,7 +26,7 @@ export type FollowState =
| { readonly type: "returning"; readonly attempt: number; readonly gesture?: FollowGesture }
/** Stays where the reader is. `endPending`: a scroll landed within the zoom slack of the end and may have reached it. */
| { readonly type: "detached"; readonly gesture?: FollowGesture; readonly endPending: boolean }
/** Holds a revealed tool call or the unread-messages notice in place. */
/** Holds a revealed tool call in place. */
| { readonly type: "anchored"; readonly anchor: FollowAnchor }
export type FollowEvent =
@@ -75,7 +75,6 @@ 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
@@ -96,19 +95,14 @@ type MessageTimelineProps = {
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => 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,
caughtUp: () => props.caughtUp,
})
const controller = createTimelineController({ session: props.session, active: () => props.active !== false })
const tail = props.follow.following() ? controller.data.projection.rows().at(-1) : undefined
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
@@ -193,8 +187,6 @@ function MessageTimelineView(
projection,
showHeader,
follow: props.follow,
onReadEnd: props.data.newMessages.read,
onReadMarkPassed: props.data.newMessages.release,
scroll: () => props.scroll,
onResumeScroll: props.onResumeScroll,
setScrollRef: props.setScrollRef,
@@ -208,7 +200,6 @@ function MessageTimelineView(
if (
Predicate.isTagged(row, "TurnGap") ||
Predicate.isTagged(row, "TurnDivider") ||
Predicate.isTagged(row, "NewMessages") ||
Predicate.isTagged(row, "CompactionQueued") ||
Predicate.isTagged(row, "Activity")
)
@@ -1,49 +0,0 @@
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",
])
})
})
@@ -1,138 +0,0 @@
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),
}
}
@@ -101,8 +101,6 @@ type Input = {
bottomInset?: Accessor<number>
setVisibleRef?: (element: HTMLDivElement | undefined) => void
onScheduleScrollState: (element: HTMLDivElement) => void
onReadEnd?: () => void
onReadMarkPassed?: () => void
onUserScroll: (target?: EventTarget | null) => void
onHistoryScroll: () => void
canRenderImmediately?: (
@@ -111,7 +109,7 @@ type Input = {
) => boolean
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
setRevealPart?: (fn: (target: string) => boolean) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => 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. */
@@ -253,77 +251,6 @@ export function createTimelineVirtualizer(input: Input) {
const touchDragEvent = { type: "touch.drag" as const, overflow: false }
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
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() || untrack(coldHidden) || !root || (newMessagesKey() && !readIntent) || !atReachableEnd(root)) return
input.onReadEnd?.()
releaseReadMark()
}
const landNewMessages = () => {
const key = newMessagesKey()
const root = listRoot()
const state = input.follow.peek()
// A queued return to the end reads the notice once it gets there.
if (untrack(coldHidden) || !key || key === landedNewMessages || !root?.isConnected || state.type === "returning")
return false
if (
state.type === "detached" ||
(state.type === "anchored" && state.anchor.partID) ||
location.hash ||
input.reveal?.target()
) {
landedNewMessages = key
return false
}
// A list that does not overflow already shows the reply; wait for its measured height.
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
if (root.scrollHeight - root.clientHeight <= MIN_OVERFLOW) return false
landedNewMessages = key
readIntent = false
prepareNavigation()
input.follow.dispatch({ type: "anchor", anchor: { key } })
virtualizer.scrollToIndex(rowKeys().indexOf(key), { align: "start" })
return true
}
const [headerHeight, setHeaderHeight] = createSignal(48)
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
@@ -378,9 +305,6 @@ export function createTimelineVirtualizer(input: Input) {
)
})
dispatchCold(CHECK_REQUESTED)
if (!scrolling) readEnd()
releaseReadMark()
}
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
@@ -508,16 +432,11 @@ export function createTimelineVirtualizer(input: Input) {
if (untrack(coldHidden)) pinColdBottom()
dispatchCold(CHECK_REQUESTED)
if (untrack(coldHidden)) return
landNewMessages()
if (!input.follow.following()) return
if (untrack(coldHidden) || !input.follow.following()) return
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && !atReachableEnd(root)) virtualizer.scrollToEnd()
readEnd()
})
}
@@ -599,11 +518,7 @@ export function createTimelineVirtualizer(input: Input) {
if (!anchor || !root) return
const element = virtualContent?.querySelector<HTMLElement>(
anchor.partID
? `[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`
: `[data-timeline-key="${CSS.escape(anchor.key)}"]`,
)
const element = virtualContent?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`)
if (!element || !active()) return input.follow.dispatch({ type: "release" })
@@ -614,9 +529,7 @@ export function createTimelineVirtualizer(input: Input) {
root.scrollTop +
element.getBoundingClientRect().top -
root.getBoundingClientRect().top -
(anchor.partID
? parseFloat(getComputedStyle(element).scrollMarginTop)
: virtualizer.options.scrollPaddingStart),
parseFloat(getComputedStyle(element).scrollMarginTop),
),
)
@@ -651,12 +564,7 @@ export function createTimelineVirtualizer(input: Input) {
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
createEffect(() => {
if (!active()) {
landedNewMessages = undefined
return
}
if (!active()) return
const root = listRoot()
if (root) input.setScrollRef(root)
@@ -671,11 +579,10 @@ export function createTimelineVirtualizer(input: Input) {
const target = input.reveal?.target()
if (target && revealPart(target)) input.reveal?.done()
else if (!landNewMessages() && input.follow.following()) virtualizer.scrollToEnd()
else if (input.follow.following()) virtualizer.scrollToEnd()
reportOffset?.(root.scrollTop, false)
dispatchCold(CHECK_REQUESTED)
readEnd()
})
input.setRevealPart?.(revealPart)
input.setRevealMessage?.((id, partID) => {
@@ -697,20 +604,10 @@ export function createTimelineVirtualizer(input: Input) {
prepareNavigation()
virtualizer.scrollToIndex(index, { align: partIndex >= 0 ? "center" : "start" })
})
input.setScrollToEnd?.((behavior, read) => {
input.setScrollToEnd?.((behavior) => {
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
}
flushTouchAdjustment()
input.follow.dispatch({ type: "follow" })
@@ -718,7 +615,6 @@ 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()
@@ -727,31 +623,12 @@ export function createTimelineVirtualizer(input: Input) {
prepareNavigation()
input.onUserScroll(root)
dispatchIntent(direction < 0)
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 = input.follow.anchor()
// A held notice moves to the newest unread mark, or lets go once it is read. Every cached view reads the selected
// session's follow state, so only the active one may change it.
if (active() && anchor && !anchor.partID && anchor.key !== key)
input.follow.dispatch(key ? { type: "anchor", anchor: { key } } : { type: "release" })
if (active())
queueMicrotask(() => {
if (!active()) return
landNewMessages()
releaseReadMark()
})
})
createEffect(
on(
bottomInset,
@@ -886,13 +763,6 @@ export function createTimelineVirtualizer(input: Input) {
return
}
if (effect.type === "reveal") {
landNewMessages()
readEnd()
return
}
clearTimeout(revealTimer)
contentObserver?.disconnect()
viewportObserver?.disconnect()
@@ -976,17 +846,12 @@ export function createTimelineVirtualizer(input: Input) {
}
const handleListWheel = (event: WheelEvent) => {
readIntent = true
if (!active()) return
input.onUserScroll(event.target)
dispatchIntent(event.deltaY < 0)
if (event.deltaY > 0) readEnd()
}
const handleListTouchStart = (event: TouchEvent) => {
readIntent = true
clearTouchTarget()
if (!active()) return
@@ -1049,8 +914,6 @@ export function createTimelineVirtualizer(input: Input) {
// Not the secondary button: it opens a context menu that can swallow its release. The middle button stays, as its
// drag autoscrolls on Windows and Linux.
const handleListPointerDown = (event: PointerEvent) => {
readIntent = true
if (!active() || event.button === 2) return
input.onUserScroll(event.target)
input.follow.dispatch(pointerDownEvent)
@@ -1085,7 +948,6 @@ export function createTimelineVirtualizer(input: Input) {
if (!isScrollKeyTarget(event.target, key)) return
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
readIntent = true
input.onUserScroll(event.currentTarget)
dispatchIntent(upwardKeys.has(key))
}
@@ -79,20 +79,6 @@ 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,7 +93,6 @@ describe("settings schema", () => {
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
showTurnDiffs: false,
newMessagesDivider: true,
backgroundActivity: "left",
timelineWidth: "comfortable",
newIcons: false,
-9
View File
@@ -125,7 +125,6 @@ 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.
@@ -303,7 +302,6 @@ export const defaultSettings: Settings = {
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
showTurnDiffs: false,
newMessagesDivider: true,
backgroundActivity: "left",
timelineWidth: "comfortable",
newIcons: false,
@@ -405,13 +403,6 @@ 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,13 +125,6 @@ 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: "experimental",
label: "settings.experimental.row.sidebarOrganize.title",
@@ -6,7 +6,6 @@ import type {
SessionMessageInfo,
} from "@opencode/client/promise"
import { createStore } from "solid-js/store"
import { Predicate } from "effect"
import {
createTimelineProjection,
reasoningHeading,
@@ -255,59 +254,6 @@ 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
+2 -34
View File
@@ -93,8 +93,6 @@ export type TimelineProjectionInput = {
queuedCompactionIDs?: readonly string[]
turnDiffs?: boolean
previousRows?: TimelineRow.TimelineRow[]
newMessages?: ReadonlySet<string>
newMessagesRead?: boolean
}
export function createTimelineProjection(input: TimelineProjectionInput) {
@@ -113,11 +111,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
input.turnDiffs,
)
const rows = reuseTimelineRows(
input.previousRows,
withNewMessages(projection.rows, input.newMessages, input.newMessagesRead),
)
const rows = reuseTimelineRows(input.previousRows, projection.rows)
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>()
@@ -157,8 +151,6 @@ 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)),
@@ -223,7 +215,7 @@ export function createReactiveTimelineProjection(input: {
})
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, withNewMessages(projection().rows, input.newMessages?.(), input.newMessagesRead?.())),
reuseTimelineRows(previous, projection().rows),
)
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
@@ -270,30 +262,6 @@ 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[],
@@ -1029,29 +1029,6 @@ 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,12 +45,6 @@ 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
@@ -114,7 +108,6 @@ export namespace TimelineRow {
| Shell
| Notice
| TurnDivider
| NewMessages
| AssistantPart
| Thinking
| Error
@@ -129,7 +122,6 @@ 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.
@@ -152,8 +144,6 @@ 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 &&
@@ -212,7 +202,6 @@ 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,7 +312,6 @@ 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",