diff --git a/packages/app/e2e/regression/timeline-history.spec.ts b/packages/app/e2e/regression/timeline-history.spec.ts index 472d84c7833..273176e6182 100644 --- a/packages/app/e2e/regression/timeline-history.spec.ts +++ b/packages/app/e2e/regression/timeline-history.spec.ts @@ -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[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 dd4327c33ca..575e88fbf78 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 5f2c89f5257..a01d31745a4 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -300,6 +300,7 @@ function SessionScreenContent(props: { } -export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor }) { +export function createTimelineController(input: { + session: TimelineSessionSource + active: Accessor + caughtUp: Accessor +}) { 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, diff --git a/packages/app/src/session/timeline/interaction.ts b/packages/app/src/session/timeline/interaction.ts index d1c5d928d5f..aba80c8081c 100644 --- a/packages/app/src/session/timeline/interaction.ts +++ b/packages/app/src/session/timeline/interaction.ts @@ -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) => { diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 122e28620a4..765ec514a8c 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -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") ) diff --git a/packages/app/src/session/timeline/new-messages.test.ts b/packages/app/src/session/timeline/new-messages.test.ts new file mode 100644 index 00000000000..8e8b5cc52a5 --- /dev/null +++ b/packages/app/src/session/timeline/new-messages.test.ts @@ -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", + ]) + }) +}) diff --git a/packages/app/src/session/timeline/new-messages.ts b/packages/app/src/session/timeline/new-messages.ts new file mode 100644 index 00000000000..b73cb689ac5 --- /dev/null +++ b/packages/app/src/session/timeline/new-messages.ts @@ -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 = 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 e68d0cc3775..c395035cc8e 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -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(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`) + + const element = virtualContent?.querySelector( + 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) diff --git a/packages/app/src/settings/experimental/experimental.tsx b/packages/app/src/settings/experimental/experimental.tsx index 3128ae89092..730eb4cfb41 100644 --- a/packages/app/src/settings/experimental/experimental.tsx +++ b/packages/app/src/settings/experimental/experimental.tsx @@ -64,6 +64,20 @@ 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 2d0899926e8..037b0d5bd14 100644 --- a/packages/app/src/settings/model.test.ts +++ b/packages/app/src/settings/model.test.ts @@ -93,6 +93,7 @@ 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 5e8f8f164c5..4a14477fd2d 100644 --- a/packages/app/src/settings/model.tsx +++ b/packages/app/src/settings/model.tsx @@ -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, diff --git a/packages/app/src/settings/search-catalog.ts b/packages/app/src/settings/search-catalog.ts index 9f5d783d569..5e56244f71c 100644 --- a/packages/app/src/settings/search-catalog.ts +++ b/packages/app/src/settings/search-catalog.ts @@ -125,6 +125,13 @@ 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: "appearance", label: "settings.general.row.colorScheme.title", diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 5bae029692e..34aaec70c6b 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -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 diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 336b1e259c9..9c39c0bed20 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -82,6 +82,8 @@ export type TimelineProjectionInput = { queuedCompactionIDs?: readonly string[] turnDiffs?: boolean previousRows?: TimelineRow.TimelineRow[] + newMessages?: ReadonlySet + 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() const messageLastRowIndex = new Map() @@ -139,6 +145,8 @@ 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)), @@ -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, 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 218a2d097b2..d4a5b4e34dd 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -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 ( + +
+ +
+ + ) + } + 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 fca3e8c7158..34c519e5ed7 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -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 diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index d510b4d4769..15fc5a099f5 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",