Skip to content

Commit 8568952

Browse files
authored
feat(app): add return sessions to the first unread message experiment (#61663)
1 parent 5a750e6 commit 8568952

18 files changed

Lines changed: 758 additions & 17 deletions

File tree

‎packages/app/e2e/regression/timeline-history.spec.ts‎

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
sessionID,
1212
setupTimeline,
1313
status,
14+
stepStarted,
1415
textPart,
1516
userMessage,
1617
validateTimelineMessages,
@@ -21,6 +22,221 @@ import { openSession } from "../utils/workspace"
2122
test.use({ serviceWorkers: "block" })
2223

2324
test.describe("timeline history", () => {
25+
test("reopens at the first locally unseen reply", async ({ page }) => {
26+
const notice = await openUnreadSession(page)
27+
28+
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
29+
await expect(page.getByText("New paragraph 0.", { exact: true })).toBeInViewport()
30+
})
31+
32+
test("reopens at the end when the new-messages divider is disabled", async ({ page }) => {
33+
const notice = await openUnreadSession(page, { disabled: true })
34+
35+
await expect(page.getByText("New paragraph 59.", { exact: true })).toBeInViewport()
36+
await expect(notice).toHaveCount(0)
37+
})
38+
39+
test("reaching the end dismisses the new-messages divider without moving the reply", async ({ page }) => {
40+
const notice = await openUnreadSession(page, { paragraphs: 5 })
41+
42+
const paragraph = page.getByText("New paragraph 4.", { exact: true })
43+
44+
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
45+
await timelineScroller(page).evaluate((element) => {
46+
element.scrollTop = element.scrollHeight
47+
})
48+
await expect(paragraph).toBeInViewport()
49+
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
50+
const top = await paragraph.evaluate((element) => element.getBoundingClientRect().top)
51+
52+
await timelineScroller(page).dispatchEvent("wheel", { deltaY: 1 })
53+
await expect(notice.getByText("New", { exact: true })).toBeHidden()
54+
await expect.poll(() => paragraph.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top, 0)
55+
})
56+
57+
test("returning to a cached session lands on work streamed while another tab was open", async ({ page }) => {
58+
const other = "ses_new_other"
59+
60+
let history = [userMessage(), assistantMessage([textPart("prt_cached_seen", "Read before switching")])]
61+
62+
const workspace = await openSession(page, {
63+
name: "NewMessagesCached",
64+
sessions: [{ id: sessionID }, { id: other, directory: "C:/OpenCode/UnreadOther" }],
65+
pageMessages: (id) => ({
66+
items: id === other ? [userMessage([textPart("prt_other", "Other session")])] : history,
67+
}),
68+
})
69+
70+
await expect(page.getByText("Read before switching", { exact: true })).toBeVisible()
71+
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(other)}"]`).click()
72+
await expect(page.getByText("Other session", { exact: true })).toBeVisible()
73+
74+
const text = textPart(
75+
"prt_cached_reply",
76+
Array.from({ length: 40 }, (_, index) => `Cached reply ${index}.`).join("\n\n"),
77+
)
78+
79+
const reply = assistantMessage([], { id: "msg_cached_new_reply", completed: false, created: 1700000002000 })
80+
81+
history = [
82+
...history,
83+
{ ...reply, content: [{ type: "text", text: text.text }], time: { ...reply.time, completed: 1700000003000 } },
84+
]
85+
86+
await workspace.push([
87+
status("busy"),
88+
stepStarted(reply),
89+
...partUpdated({
90+
...text,
91+
messageID: reply.id,
92+
}),
93+
messageUpdated(reply),
94+
status("idle"),
95+
status("busy", 1, other),
96+
])
97+
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(sessionID)}"]`)
98+
99+
// Wait for the stream to reach the active session before switching back.
100+
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
101+
await tab.click()
102+
await expect(
103+
page.locator('[data-slot="session-timeline-notice"]').getByText("New", { exact: true }),
104+
).toBeInViewport()
105+
await expect(page.getByText("Cached reply 0.", { exact: true })).toBeInViewport()
106+
await page.getByRole("button", { name: "Jump to latest" }).click()
107+
await expect(page.getByText("Cached reply 39.", { exact: true })).toBeInViewport()
108+
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
109+
})
110+
111+
test("marks new work above an undelivered steer", async ({ page }) => {
112+
const id = "ses_unread_steer"
113+
114+
let messages = [
115+
userMessage([textPart("prt_unread_prompt", "Read the code")], { id: "msg_1000_unread_prompt", created: 1 }),
116+
assistantMessage([textPart("prt_unread_seen", "Read work")], {
117+
id: "msg_1001_unread_work",
118+
parentID: "msg_1000_unread_prompt",
119+
created: 2,
120+
completed: false,
121+
}),
122+
]
123+
124+
await openSession(page, {
125+
name: "UnreadSteer",
126+
sessions: [{ id }],
127+
sessionStatus: { [id]: { type: "busy" } },
128+
pageMessages: () => ({ items: messages }),
129+
inbox: [
130+
{
131+
id: "msg_1002_unread_steer",
132+
sessionID: id,
133+
type: "user",
134+
delivery: "steer",
135+
payload: { text: "Waiting prompt" },
136+
time: { created: 3 },
137+
},
138+
],
139+
})
140+
await expect(
141+
page.locator('[data-timeline-row="UserMessage"]').getByText("Waiting prompt", { exact: true }),
142+
).toBeVisible()
143+
await expect(page.getByText("Read work", { exact: true })).toBeVisible()
144+
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
145+
messages = [
146+
messages[0],
147+
assistantMessage(
148+
[
149+
textPart("prt_unread_seen", "Read work"),
150+
textPart("prt_unread_new", Array.from({ length: 60 }, (_, index) => `Unseen work ${index}.`).join("\n\n")),
151+
],
152+
{ id: "msg_1001_unread_work", parentID: "msg_1000_unread_prompt", created: 2, completed: false },
153+
),
154+
]
155+
await page.reload()
156+
await expect(page.locator('[data-timeline-row="NewMessages"]').getByText("New", { exact: true })).toBeInViewport()
157+
await expect(page.getByText("Unseen work 0.", { exact: true })).toBeInViewport()
158+
})
159+
160+
test("lands at the true unread boundary when older unread history loads", async ({ page }) => {
161+
const id = "ses_unread_pages"
162+
163+
let messages: SessionMessageInfo[] = [
164+
userMessage([textPart("prt_unread_pages_prompt", "Keep working")], { id: "msg_0000_unread_pages", created: 1 }),
165+
assistantMessage([textPart("prt_unread_pages_seen", "Read before leaving")], {
166+
id: "msg_0001_unread_pages",
167+
parentID: "msg_0000_unread_pages",
168+
created: 2,
169+
}),
170+
]
171+
172+
await openSession(page, {
173+
name: "UnreadPages",
174+
sessions: [{ id }],
175+
pageMessages: (_id, _limit, before) => {
176+
const end = before ? Number(before) : messages.length
177+
178+
const start = Math.max(0, end - 20)
179+
180+
return { items: messages.slice(start, end), cursor: start ? String(start) : undefined }
181+
},
182+
})
183+
await expect(page.getByText("Read before leaving", { exact: true })).toBeVisible()
184+
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
185+
// The unread span exceeds the initial page and three enrichment pages.
186+
messages = [
187+
...messages,
188+
...Array.from({ length: 90 }, (_, index) =>
189+
assistantMessage([textPart(`prt_unread_page_${index}`, `Unread response ${index}`)], {
190+
id: `msg_${String(index + 2).padStart(4, "0")}_unread_pages`,
191+
parentID: "msg_0000_unread_pages",
192+
created: index + 3,
193+
}),
194+
),
195+
]
196+
await page.reload()
197+
const notice = page.locator('[data-timeline-key="new-messages:msg_0002_unread_pages:text:0"]')
198+
199+
await expect(notice).toBeInViewport()
200+
await expect(page.getByText("Unread response 0", { exact: true })).toBeInViewport()
201+
})
202+
203+
test("keeps unread replies before a prompt sent from another client", async ({ page }) => {
204+
const id = "ses_unread_remote"
205+
206+
let messages = [
207+
userMessage([textPart("prt_remote_prompt", "Start working")], { id: "msg_remote_0", created: 1 }),
208+
assistantMessage([textPart("prt_remote_seen", "Already read")], { id: "msg_remote_1", created: 2 }),
209+
]
210+
211+
const view = await openSession(page, {
212+
name: "UnreadRemote",
213+
sessions: [{ id }],
214+
pageMessages: () => ({ items: messages }),
215+
})
216+
217+
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
218+
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
219+
messages = [
220+
...messages,
221+
assistantMessage([textPart("prt_remote_unread", "Unread before the other client's prompt")], {
222+
id: "msg_remote_2",
223+
created: 3,
224+
}),
225+
userMessage([textPart("prt_remote_other", "Sent from another client")], { id: "msg_remote_3", created: 4 }),
226+
assistantMessage(
227+
[textPart("prt_remote_reply", Array.from({ length: 40 }, (_, index) => `Remote reply ${index}.`).join("\n\n"))],
228+
{ id: "msg_remote_4", created: 5 },
229+
),
230+
]
231+
await page.reload()
232+
await expect(page.locator('[data-timeline-key="new-messages:msg_remote_2:text:0"]')).toBeInViewport()
233+
await expect(page.getByText("Unread before the other client's prompt", { exact: true })).toBeInViewport()
234+
await view.editor.fill("Continue from this client")
235+
await view.editor.press("Enter")
236+
await expect(page.getByText("Remote reply 39.", { exact: true })).toBeInViewport()
237+
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
238+
})
239+
24240
test("restoring a paged transcript waits for another upward scroll before loading history", async ({ page }) => {
25241
const id = "ses_history_restore"
26242
const other = "ses_history_other"
@@ -510,6 +726,40 @@ declare global {
510726
}
511727
}
512728

729+
async function openUnreadSession(page: Page, input: { disabled?: boolean; paragraphs?: number } = {}) {
730+
const id = "ses_new_messages"
731+
732+
let messages = [
733+
userMessage([textPart("prt_new_prompt", "Explain the implementation")], { id: "msg_new_prompt", created: 1 }),
734+
assistantMessage([textPart("prt_seen", "Already read")], { id: "msg_seen", created: 2 }),
735+
]
736+
737+
await openSession(page, {
738+
name: "NewMessages",
739+
sessions: [{ id }],
740+
pageMessages: () => ({ items: messages }),
741+
seed: input.disabled ? { settings: { general: { newMessagesDivider: false } } } : undefined,
742+
})
743+
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
744+
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
745+
messages = [
746+
...messages,
747+
assistantMessage(
748+
[
749+
textPart(
750+
"prt_new_reply",
751+
Array.from({ length: input.paragraphs ?? 60 }, (_, index) => `New paragraph ${index}.`).join("\n\n"),
752+
),
753+
],
754+
{ id: "msg_new_reply", created: 3 },
755+
),
756+
]
757+
await page.reload()
758+
await expectSessionTitle(page, id)
759+
760+
return page.locator('[data-timeline-row="NewMessages"]')
761+
}
762+
513763
async function openStressTarget(page: Page, input: Parameters<typeof mockStressTimeline>[1] = {}) {
514764
await mockStressTimeline(page, input)
515765
await installTimelineSettings(page)

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,9 @@ export const dict = {
982982
"settings.timeline.custom": "Custom",
983983
"settings.timeline.row.turnDiffs.title": "Changed files",
984984
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
985+
"settings.timeline.row.newMessagesDivider.title": "New messages divider",
986+
"settings.timeline.row.newMessagesDivider.description":
987+
"When you return to a session, jump to the first unread message.",
985988
"settings.timeline.advanced": "Advanced",
986989
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
987990
"settings.timeline.group": "Group",

‎packages/app/src/session/screen.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ function SessionScreenContent(props: {
300300
<PluginLinks session={own()} active={active()}>
301301
<MessageTimeline
302302
active={active()}
303+
caughtUp={timeline.resource.state === "ready" && timeline.resource.latest === source.identity.sessionID()}
303304
hideHeader={!isDesktop()}
304305
registerHeaderGap={registerHeaderGap}
305306
session={source}

‎packages/app/src/session/timeline/controller.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createTimelineProjection } from "./projection"
2424
import { useServer } from "@/runtime/server/current"
2525
import { getSessionMessageHandoff } from "@/session/handoff"
2626
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
27+
import { createNewMessages } from "./new-messages"
2728

2829
const emptyMessages: SessionMessageInfo[] = []
2930

@@ -56,7 +57,11 @@ export type TimelineSessionSource = {
5657
history: Pick<SessionModel["history"], "messages">
5758
}
5859

59-
export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor<boolean> }) {
60+
export function createTimelineController(input: {
61+
session: TimelineSessionSource
62+
active: Accessor<boolean>
63+
caughtUp: Accessor<boolean>
64+
}) {
6065
const navigate = useNavigate()
6166
const sdk = useWorkspaceLocation()
6267
const serverSDK = useServerSDK()
@@ -181,6 +186,17 @@ export function createTimelineController(input: { session: TimelineSessionSource
181186
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
182187
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
183188

189+
const newMessages = createNewMessages({
190+
scope: serverSDK.scope,
191+
directory: sdk().directory,
192+
sessionID: input.session.identity.sessionID()!,
193+
messages: projectedMessages,
194+
active: input.active,
195+
enabled: settings.general.newMessagesDivider,
196+
caughtUp: input.caughtUp,
197+
pendingInputIDs,
198+
})
199+
184200
const projection = createTimelineProjection({
185201
sessionMessages: projectedMessages,
186202
status: input.session.data.status,
@@ -191,6 +207,8 @@ export function createTimelineController(input: { session: TimelineSessionSource
191207
pendingInputIDs,
192208
queuedCompactionIDs,
193209
turnDiffs: settings.general.showTurnDiffs,
210+
newMessages: newMessages.parts,
211+
newMessagesRead: newMessages.dismissed,
194212
})
195213

196214
const [pending, setPending] = createStore({ rename: false })
@@ -340,6 +358,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
340358
childTitle,
341359
showHeader,
342360
projection,
361+
newMessages,
343362
timelineDetail,
344363
reasoningMode,
345364
shellToolPartsExpanded,

‎packages/app/src/session/timeline/interaction.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
6464

6565
let revealPart = (_target: string) => false
6666

67-
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
67+
let scrollToEnd = (_behavior?: ScrollBehavior, _read?: boolean) => {}
6868

6969
let page = (_direction: 1 | -1) => {}
7070

@@ -186,7 +186,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
186186
resumeFrame?.()
187187
resumeFrame = frameLoop.add(() => {
188188
resumeFrame = undefined
189-
scrollToEnd(behavior)
189+
scrollToEnd(behavior, true)
190190

191191
return false
192192
})
@@ -441,7 +441,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
441441
},
442442
},
443443
setScrollRef,
444-
setScrollToEnd: (scroll: (behavior?: ScrollBehavior) => void) => {
444+
setScrollToEnd: (scroll: (behavior?: ScrollBehavior, read?: boolean) => void) => {
445445
scrollToEnd = scroll
446446
},
447447
setPage: (scroll: (direction: 1 | -1) => void) => {

0 commit comments

Comments
 (0)