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