diff --git a/packages/app/e2e/regression/timeline-history.spec.ts b/packages/app/e2e/regression/timeline-history.spec.ts index 273176e6182..472d84c7833 100644 --- a/packages/app/e2e/regression/timeline-history.spec.ts +++ b/packages/app/e2e/regression/timeline-history.spec.ts @@ -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[1] = {}) { await mockStressTimeline(page, input) await installTimelineSettings(page) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index c9ce87965d6..f4ec62d125d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 5e33eb0f9b7..db34fb413f8 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -301,7 +301,6 @@ function SessionScreenContent(props: { { 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) }, ) diff --git a/packages/app/src/session/timeline/cold-mount.ts b/packages/app/src/session/timeline/cold-mount.ts index 8e65ab016b0..32f61da9e8e 100644 --- a/packages/app/src/session/timeline/cold-mount.ts +++ b/packages/app/src/session/timeline/cold-mount.ts @@ -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, attempt: number) { diff --git a/packages/app/src/session/timeline/controller.tsx b/packages/app/src/session/timeline/controller.tsx index 74f18e39d69..eb072c92233 100644 --- a/packages/app/src/session/timeline/controller.tsx +++ b/packages/app/src/session/timeline/controller.tsx @@ -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 } -export function createTimelineController(input: { - session: TimelineSessionSource - active: Accessor - caughtUp: Accessor -}) { +export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor }) { 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, diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index 57ec75dd2e7..fdbcfa24ead 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -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) => { diff --git a/packages/app/src/session/timeline/machine.ts b/packages/app/src/session/timeline/machine.ts index 36c299fbc69..0b621113c7b 100644 --- a/packages/app/src/session/timeline/machine.ts +++ b/packages/app/src/session/timeline/machine.ts @@ -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 = diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index ddf49ec4444..4d94b557156 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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") ) diff --git a/packages/app/src/session/timeline/new-messages.test.ts b/packages/app/src/session/timeline/new-messages.test.ts deleted file mode 100644 index 8e8b5cc52a5..00000000000 --- a/packages/app/src/session/timeline/new-messages.test.ts +++ /dev/null @@ -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", - ]) - }) -}) diff --git a/packages/app/src/session/timeline/new-messages.ts b/packages/app/src/session/timeline/new-messages.ts deleted file mode 100644 index b73cb689ac5..00000000000 --- a/packages/app/src/session/timeline/new-messages.ts +++ /dev/null @@ -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 = 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 - active: Accessor - enabled: Accessor - caughtUp: Accessor - pendingInputIDs: Accessor> -}) { - 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 - 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), - } -} diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 4ca292c9fc6..c1138a05e8e 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -101,8 +101,6 @@ type Input = { bottomInset?: Accessor 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({ @@ -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( - anchor.partID - ? `[data-timeline-part-id="${CSS.escape(anchor.partID)}"]` - : `[data-timeline-key="${CSS.escape(anchor.key)}"]`, - ) + const element = virtualContent?.querySelector(`[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)) } diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index 27af455453d..81aad8ab232 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -79,20 +79,6 @@ export const SettingsExperimental: Component = () => { - -
- - {language.t("settings.timeline.row.newMessagesDivider.title")} - -
-
diff --git a/packages/app/src/settings/model.test.ts b/packages/app/src/settings/model.test.ts index d1bd8307ada..d499712d1f3 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -93,7 +93,6 @@ describe("settings schema", () => { timelineDetail: timelinePresets[2].value, showCustomAgents: false, showTurnDiffs: false, - newMessagesDivider: true, backgroundActivity: "left", timelineWidth: "comfortable", newIcons: false, diff --git a/packages/app/src/settings/model.tsx b/packages/app/src/settings/model.tsx index 9608507ec81..6df3823e009 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -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, diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index 7af09c8ced6..7429fa446a2 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -125,13 +125,6 @@ export const clientSettings: Entry[] = [ 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", diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 34aaec70c6b..5bae029692e 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -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 diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 7fad08cfbcf..74b0ea04323 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -93,8 +93,6 @@ export type TimelineProjectionInput = { queuedCompactionIDs?: readonly string[] turnDiffs?: boolean previousRows?: TimelineRow.TimelineRow[] - newMessages?: ReadonlySet - 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() const messageLastRowIndex = new Map() @@ -157,8 +151,6 @@ export function createReactiveTimelineProjection(input: { queuedCompactionIDs?: Accessor /** Summarize each finished turn's changed files after it. */ turnDiffs?: Accessor - newMessages?: Accessor> - newMessagesRead?: Accessor }) { 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, 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[], diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index c35a47b7a41..10b4bd729ff 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 ( - -
- -
- - ) - } - if (Predicate.isTagged(row(), "TurnDivider")) { const current = () => { const value = row() diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 05c4212f0bc..1631e51ae3b 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -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 diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 15fc5a099f5..d510b4d4769 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",