mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
fix(app): remove the new messages divider (#61878)
This commit is contained in:
committed by
GitHub
Unverified
parent
14638b54d3
commit
a4b754e740
@@ -11,7 +11,6 @@ import {
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
stepStarted,
|
||||
textPart,
|
||||
userMessage,
|
||||
validateTimelineMessages,
|
||||
@@ -22,221 +21,6 @@ import { openSession } from "../utils/workspace"
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
test.describe("timeline history", () => {
|
||||
test("reopens at the first locally unseen reply", async ({ page }) => {
|
||||
const notice = await openUnreadSession(page)
|
||||
|
||||
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
|
||||
await expect(page.getByText("New paragraph 0.", { exact: true })).toBeInViewport()
|
||||
})
|
||||
|
||||
test("reopens at the end when the new-messages divider is disabled", async ({ page }) => {
|
||||
const notice = await openUnreadSession(page, { disabled: true })
|
||||
|
||||
await expect(page.getByText("New paragraph 59.", { exact: true })).toBeInViewport()
|
||||
await expect(notice).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("reaching the end dismisses the new-messages divider without moving the reply", async ({ page }) => {
|
||||
const notice = await openUnreadSession(page, { paragraphs: 5 })
|
||||
|
||||
const paragraph = page.getByText("New paragraph 4.", { exact: true })
|
||||
|
||||
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
|
||||
await timelineScroller(page).evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
})
|
||||
await expect(paragraph).toBeInViewport()
|
||||
await expect(notice.getByText("New", { exact: true })).toBeInViewport()
|
||||
const top = await paragraph.evaluate((element) => element.getBoundingClientRect().top)
|
||||
|
||||
await timelineScroller(page).dispatchEvent("wheel", { deltaY: 1 })
|
||||
await expect(notice.getByText("New", { exact: true })).toBeHidden()
|
||||
await expect.poll(() => paragraph.evaluate((element) => element.getBoundingClientRect().top)).toBeCloseTo(top, 0)
|
||||
})
|
||||
|
||||
test("returning to a cached session lands on work streamed while another tab was open", async ({ page }) => {
|
||||
const other = "ses_new_other"
|
||||
|
||||
let history = [userMessage(), assistantMessage([textPart("prt_cached_seen", "Read before switching")])]
|
||||
|
||||
const workspace = await openSession(page, {
|
||||
name: "NewMessagesCached",
|
||||
sessions: [{ id: sessionID }, { id: other, directory: "C:/OpenCode/UnreadOther" }],
|
||||
pageMessages: (id) => ({
|
||||
items: id === other ? [userMessage([textPart("prt_other", "Other session")])] : history,
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(page.getByText("Read before switching", { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(other)}"]`).click()
|
||||
await expect(page.getByText("Other session", { exact: true })).toBeVisible()
|
||||
|
||||
const text = textPart(
|
||||
"prt_cached_reply",
|
||||
Array.from({ length: 40 }, (_, index) => `Cached reply ${index}.`).join("\n\n"),
|
||||
)
|
||||
|
||||
const reply = assistantMessage([], { id: "msg_cached_new_reply", completed: false, created: 1700000002000 })
|
||||
|
||||
history = [
|
||||
...history,
|
||||
{ ...reply, content: [{ type: "text", text: text.text }], time: { ...reply.time, completed: 1700000003000 } },
|
||||
]
|
||||
|
||||
await workspace.push([
|
||||
status("busy"),
|
||||
stepStarted(reply),
|
||||
...partUpdated({
|
||||
...text,
|
||||
messageID: reply.id,
|
||||
}),
|
||||
messageUpdated(reply),
|
||||
status("idle"),
|
||||
status("busy", 1, other),
|
||||
])
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(sessionID)}"]`)
|
||||
|
||||
// Wait for the stream to reach the active session before switching back.
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await tab.click()
|
||||
await expect(
|
||||
page.locator('[data-slot="session-timeline-notice"]').getByText("New", { exact: true }),
|
||||
).toBeInViewport()
|
||||
await expect(page.getByText("Cached reply 0.", { exact: true })).toBeInViewport()
|
||||
await page.getByRole("button", { name: "Jump to latest" }).click()
|
||||
await expect(page.getByText("Cached reply 39.", { exact: true })).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("marks new work above an undelivered steer", async ({ page }) => {
|
||||
const id = "ses_unread_steer"
|
||||
|
||||
let messages = [
|
||||
userMessage([textPart("prt_unread_prompt", "Read the code")], { id: "msg_1000_unread_prompt", created: 1 }),
|
||||
assistantMessage([textPart("prt_unread_seen", "Read work")], {
|
||||
id: "msg_1001_unread_work",
|
||||
parentID: "msg_1000_unread_prompt",
|
||||
created: 2,
|
||||
completed: false,
|
||||
}),
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "UnreadSteer",
|
||||
sessions: [{ id }],
|
||||
sessionStatus: { [id]: { type: "busy" } },
|
||||
pageMessages: () => ({ items: messages }),
|
||||
inbox: [
|
||||
{
|
||||
id: "msg_1002_unread_steer",
|
||||
sessionID: id,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "Waiting prompt" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
],
|
||||
})
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"]').getByText("Waiting prompt", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("Read work", { exact: true })).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
messages = [
|
||||
messages[0],
|
||||
assistantMessage(
|
||||
[
|
||||
textPart("prt_unread_seen", "Read work"),
|
||||
textPart("prt_unread_new", Array.from({ length: 60 }, (_, index) => `Unseen work ${index}.`).join("\n\n")),
|
||||
],
|
||||
{ id: "msg_1001_unread_work", parentID: "msg_1000_unread_prompt", created: 2, completed: false },
|
||||
),
|
||||
]
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-timeline-row="NewMessages"]').getByText("New", { exact: true })).toBeInViewport()
|
||||
await expect(page.getByText("Unseen work 0.", { exact: true })).toBeInViewport()
|
||||
})
|
||||
|
||||
test("lands at the true unread boundary when older unread history loads", async ({ page }) => {
|
||||
const id = "ses_unread_pages"
|
||||
|
||||
let messages: SessionMessageInfo[] = [
|
||||
userMessage([textPart("prt_unread_pages_prompt", "Keep working")], { id: "msg_0000_unread_pages", created: 1 }),
|
||||
assistantMessage([textPart("prt_unread_pages_seen", "Read before leaving")], {
|
||||
id: "msg_0001_unread_pages",
|
||||
parentID: "msg_0000_unread_pages",
|
||||
created: 2,
|
||||
}),
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "UnreadPages",
|
||||
sessions: [{ id }],
|
||||
pageMessages: (_id, _limit, before) => {
|
||||
const end = before ? Number(before) : messages.length
|
||||
|
||||
const start = Math.max(0, end - 20)
|
||||
|
||||
return { items: messages.slice(start, end), cursor: start ? String(start) : undefined }
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Read before leaving", { exact: true })).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
// The unread span exceeds the initial page and three enrichment pages.
|
||||
messages = [
|
||||
...messages,
|
||||
...Array.from({ length: 90 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_unread_page_${index}`, `Unread response ${index}`)], {
|
||||
id: `msg_${String(index + 2).padStart(4, "0")}_unread_pages`,
|
||||
parentID: "msg_0000_unread_pages",
|
||||
created: index + 3,
|
||||
}),
|
||||
),
|
||||
]
|
||||
await page.reload()
|
||||
const notice = page.locator('[data-timeline-key="new-messages:msg_0002_unread_pages:text:0"]')
|
||||
|
||||
await expect(notice).toBeInViewport()
|
||||
await expect(page.getByText("Unread response 0", { exact: true })).toBeInViewport()
|
||||
})
|
||||
|
||||
test("keeps unread replies before a prompt sent from another client", async ({ page }) => {
|
||||
const id = "ses_unread_remote"
|
||||
|
||||
let messages = [
|
||||
userMessage([textPart("prt_remote_prompt", "Start working")], { id: "msg_remote_0", created: 1 }),
|
||||
assistantMessage([textPart("prt_remote_seen", "Already read")], { id: "msg_remote_1", created: 2 }),
|
||||
]
|
||||
|
||||
const view = await openSession(page, {
|
||||
name: "UnreadRemote",
|
||||
sessions: [{ id }],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
})
|
||||
|
||||
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
messages = [
|
||||
...messages,
|
||||
assistantMessage([textPart("prt_remote_unread", "Unread before the other client's prompt")], {
|
||||
id: "msg_remote_2",
|
||||
created: 3,
|
||||
}),
|
||||
userMessage([textPart("prt_remote_other", "Sent from another client")], { id: "msg_remote_3", created: 4 }),
|
||||
assistantMessage(
|
||||
[textPart("prt_remote_reply", Array.from({ length: 40 }, (_, index) => `Remote reply ${index}.`).join("\n\n"))],
|
||||
{ id: "msg_remote_4", created: 5 },
|
||||
),
|
||||
]
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-timeline-key="new-messages:msg_remote_2:text:0"]')).toBeInViewport()
|
||||
await expect(page.getByText("Unread before the other client's prompt", { exact: true })).toBeInViewport()
|
||||
await view.editor.fill("Continue from this client")
|
||||
await view.editor.press("Enter")
|
||||
await expect(page.getByText("Remote reply 39.", { exact: true })).toBeInViewport()
|
||||
await expect(page.locator('[data-timeline-row="NewMessages"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("restoring a paged transcript waits for another upward scroll before loading history", async ({ page }) => {
|
||||
const id = "ses_history_restore"
|
||||
const other = "ses_history_other"
|
||||
@@ -726,40 +510,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
async function openUnreadSession(page: Page, input: { disabled?: boolean; paragraphs?: number } = {}) {
|
||||
const id = "ses_new_messages"
|
||||
|
||||
let messages = [
|
||||
userMessage([textPart("prt_new_prompt", "Explain the implementation")], { id: "msg_new_prompt", created: 1 }),
|
||||
assistantMessage([textPart("prt_seen", "Already read")], { id: "msg_seen", created: 2 }),
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "NewMessages",
|
||||
sessions: [{ id }],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
seed: input.disabled ? { settings: { general: { newMessagesDivider: false } } } : undefined,
|
||||
})
|
||||
await expect(page.getByText("Already read", { exact: true })).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
messages = [
|
||||
...messages,
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(
|
||||
"prt_new_reply",
|
||||
Array.from({ length: input.paragraphs ?? 60 }, (_, index) => `New paragraph ${index}.`).join("\n\n"),
|
||||
),
|
||||
],
|
||||
{ id: "msg_new_reply", created: 3 },
|
||||
),
|
||||
]
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, id)
|
||||
|
||||
return page.locator('[data-timeline-row="NewMessages"]')
|
||||
}
|
||||
|
||||
async function openStressTarget(page: Page, input: Parameters<typeof mockStressTimeline>[1] = {}) {
|
||||
await mockStressTimeline(page, input)
|
||||
await installTimelineSettings(page)
|
||||
|
||||
@@ -1008,9 +1008,6 @@ export const dict = {
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.row.turnDiffs.title": "Changed files",
|
||||
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
|
||||
"settings.timeline.row.newMessagesDivider.title": "New messages divider",
|
||||
"settings.timeline.row.newMessagesDivider.description":
|
||||
"When you return to a session, jump to the first unread message.",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.group": "Group",
|
||||
|
||||
@@ -301,7 +301,6 @@ function SessionScreenContent(props: {
|
||||
<PluginLinks session={own()} active={active()}>
|
||||
<MessageTimeline
|
||||
active={active()}
|
||||
caughtUp={timeline.resource.state === "ready" && timeline.resource.latest === source.identity.sessionID()}
|
||||
hideHeader={!isDesktop()}
|
||||
registerHeaderGap={registerHeaderGap}
|
||||
session={source}
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("cold mount", () => {
|
||||
|
||||
const shown = run(exposed.state, ...check(1, "settled"))
|
||||
expect(shown.state).toEqual({ type: "revealed" })
|
||||
expect(shown.effects).toEqual([{ type: "release" }, { type: "reveal" }])
|
||||
expect(shown.effects).toEqual([{ type: "release" }])
|
||||
})
|
||||
|
||||
// Before the machine, a Markdown block that never set data-markdown-ready (or a view that stayed detached) kept
|
||||
@@ -96,7 +96,7 @@ describe("cold mount", () => {
|
||||
|
||||
const next = transitionColdMount(stuck.state, { type: "timeout", attempt: 1 })
|
||||
expect(next.state).toEqual({ type: "revealed" })
|
||||
expect(next.effects).toEqual([{ type: "release" }, { type: "pin" }, { type: "reveal" }])
|
||||
expect(next.effects).toEqual([{ type: "release" }, { type: "pin" }])
|
||||
expect(transitionColdMount(next.state, CHECK_REQUESTED).state).toBe(next.state)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -43,7 +43,6 @@ export type ColdMountEffect =
|
||||
| { readonly type: "check.schedule"; readonly attempt: number }
|
||||
| { readonly type: "check.sample"; readonly attempt: number }
|
||||
| { readonly type: "pin" }
|
||||
| { readonly type: "reveal" }
|
||||
| { readonly type: "release" }
|
||||
|
||||
export type ColdMountTransition = {
|
||||
@@ -130,7 +129,7 @@ export function transitionColdMount(state: ColdMountState, event: ColdMountEvent
|
||||
if (event.type === "timeout") {
|
||||
if (!current(state, event.attempt)) return quiet(state)
|
||||
|
||||
return { state: revealed, effects: [{ type: "release" }, { type: "pin" }, { type: "reveal" }] }
|
||||
return { state: revealed, effects: [{ type: "release" }, { type: "pin" }] }
|
||||
}
|
||||
|
||||
return { state: revealed, effects: [{ type: "release" }] }
|
||||
@@ -168,7 +167,7 @@ function sampled(
|
||||
|
||||
if (event.sample === "pinned") return quiet({ ...state, checking: false })
|
||||
|
||||
return { state: revealed, effects: [{ type: "release" }, { type: "reveal" }] }
|
||||
return { state: revealed, effects: [{ type: "release" }] }
|
||||
}
|
||||
|
||||
function current(state: Exclude<ColdMountState, { type: "revealed" }>, attempt: number) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { getSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ReasoningMode } from "@opencode/session-ui/timeline/projection"
|
||||
import { createNewMessages } from "./new-messages"
|
||||
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -57,11 +56,7 @@ export type TimelineSessionSource = {
|
||||
history: Pick<SessionModel["history"], "messages">
|
||||
}
|
||||
|
||||
export function createTimelineController(input: {
|
||||
session: TimelineSessionSource
|
||||
active: Accessor<boolean>
|
||||
caughtUp: Accessor<boolean>
|
||||
}) {
|
||||
export function createTimelineController(input: { session: TimelineSessionSource; active: Accessor<boolean> }) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -186,17 +181,6 @@ export function createTimelineController(input: {
|
||||
const shellToolPartsExpanded = () => timelineDetail().shell.details === "expanded"
|
||||
const editToolPartsExpanded = () => timelineDetail().edit.details === "expanded"
|
||||
|
||||
const newMessages = createNewMessages({
|
||||
scope: serverSDK.scope,
|
||||
directory: sdk().directory,
|
||||
sessionID: input.session.identity.sessionID()!,
|
||||
messages: projectedMessages,
|
||||
active: input.active,
|
||||
enabled: settings.general.newMessagesDivider,
|
||||
caughtUp: input.caughtUp,
|
||||
pendingInputIDs,
|
||||
})
|
||||
|
||||
const projection = createTimelineProjection({
|
||||
sessionMessages: projectedMessages,
|
||||
status: input.session.data.status,
|
||||
@@ -207,8 +191,6 @@ export function createTimelineController(input: {
|
||||
pendingInputIDs,
|
||||
queuedCompactionIDs,
|
||||
turnDiffs: settings.general.showTurnDiffs,
|
||||
newMessages: newMessages.parts,
|
||||
newMessagesRead: newMessages.dismissed,
|
||||
})
|
||||
|
||||
const [pending, setPending] = createStore({ rename: false })
|
||||
@@ -358,7 +340,6 @@ export function createTimelineController(input: {
|
||||
childTitle,
|
||||
showHeader,
|
||||
projection,
|
||||
newMessages,
|
||||
timelineDetail,
|
||||
reasoningMode,
|
||||
shellToolPartsExpanded,
|
||||
|
||||
@@ -55,7 +55,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
|
||||
let revealPart = (_target: string) => false
|
||||
|
||||
let scrollToEnd = (_behavior?: ScrollBehavior, _read?: boolean) => {}
|
||||
let scrollToEnd = (_behavior?: ScrollBehavior) => {}
|
||||
|
||||
let page = (_direction: 1 | -1) => {}
|
||||
|
||||
@@ -181,7 +181,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
const before = follow.peek()
|
||||
follow.dispatch({ type: "return.run", attempt })
|
||||
|
||||
if (follow.peek() !== before) scrollToEnd(behavior, true)
|
||||
if (follow.peek() !== before) scrollToEnd(behavior)
|
||||
|
||||
return false
|
||||
})
|
||||
@@ -428,7 +428,7 @@ export function createSessionTimelineInteraction(session: SessionModel) {
|
||||
},
|
||||
},
|
||||
setScrollRef,
|
||||
setScrollToEnd: (scroll: (behavior?: ScrollBehavior, read?: boolean) => void) => {
|
||||
setScrollToEnd: (scroll: (behavior?: ScrollBehavior) => void) => {
|
||||
scrollToEnd = scroll
|
||||
},
|
||||
setPage: (scroll: (direction: 1 | -1) => void) => {
|
||||
|
||||
@@ -9,8 +9,8 @@ export const ZOOM_SLACK = 4
|
||||
// A scroll range at or below this does not overflow.
|
||||
export const MIN_OVERFLOW = 1
|
||||
|
||||
/** A revealed tool call (`partID`) or the unread-messages notice, whose top edge stays put until the reader scrolls. */
|
||||
export type FollowAnchor = { readonly key: string; readonly partID?: string }
|
||||
/** A revealed tool call, whose top edge stays put until the reader scrolls. */
|
||||
export type FollowAnchor = { readonly key: string; readonly partID: string }
|
||||
|
||||
/**
|
||||
* The reader's contact with the timeline. A held pointer or touch is what separates the reader's own upward scroll
|
||||
@@ -26,7 +26,7 @@ export type FollowState =
|
||||
| { readonly type: "returning"; readonly attempt: number; readonly gesture?: FollowGesture }
|
||||
/** Stays where the reader is. `endPending`: a scroll landed within the zoom slack of the end and may have reached it. */
|
||||
| { readonly type: "detached"; readonly gesture?: FollowGesture; readonly endPending: boolean }
|
||||
/** Holds a revealed tool call or the unread-messages notice in place. */
|
||||
/** Holds a revealed tool call in place. */
|
||||
| { readonly type: "anchored"; readonly anchor: FollowAnchor }
|
||||
|
||||
export type FollowEvent =
|
||||
|
||||
@@ -75,7 +75,6 @@ type MessageTimelineProps = {
|
||||
/** Hands the header's empty gap to the pane's window drag area. */
|
||||
registerHeaderGap?: (gap: HTMLElement) => () => void
|
||||
active?: boolean
|
||||
caughtUp: boolean
|
||||
session: TimelineSessionSource
|
||||
view: MountedSession
|
||||
screen: SessionScreen
|
||||
@@ -96,19 +95,14 @@ type MessageTimelineProps = {
|
||||
anchor: (id: string) => string
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setRevealPart?: (fn: (target: string) => boolean) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
|
||||
setPage?: (fn: (direction: 1 | -1) => void) => void
|
||||
reveal?: { target: () => string | undefined; done: () => void }
|
||||
search?: JSX.Element
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: MessageTimelineProps) {
|
||||
const controller = createTimelineController({
|
||||
session: props.session,
|
||||
active: () => props.active !== false,
|
||||
caughtUp: () => props.caughtUp,
|
||||
})
|
||||
|
||||
const controller = createTimelineController({ session: props.session, active: () => props.active !== false })
|
||||
const tail = props.follow.following() ? controller.data.projection.rows().at(-1) : undefined
|
||||
|
||||
if (tail?._tag === "AssistantPart" && tail.group.type === "part") {
|
||||
@@ -193,8 +187,6 @@ function MessageTimelineView(
|
||||
projection,
|
||||
showHeader,
|
||||
follow: props.follow,
|
||||
onReadEnd: props.data.newMessages.read,
|
||||
onReadMarkPassed: props.data.newMessages.release,
|
||||
scroll: () => props.scroll,
|
||||
onResumeScroll: props.onResumeScroll,
|
||||
setScrollRef: props.setScrollRef,
|
||||
@@ -208,7 +200,6 @@ function MessageTimelineView(
|
||||
if (
|
||||
Predicate.isTagged(row, "TurnGap") ||
|
||||
Predicate.isTagged(row, "TurnDivider") ||
|
||||
Predicate.isTagged(row, "NewMessages") ||
|
||||
Predicate.isTagged(row, "CompactionQueued") ||
|
||||
Predicate.isTagged(row, "Activity")
|
||||
)
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { unseenTimelineParts } from "./new-messages"
|
||||
|
||||
const messages = [
|
||||
{ id: "msg_user", type: "user", text: "Prompt", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
time: { created: 2 },
|
||||
content: [
|
||||
{ type: "text", text: "Seen" },
|
||||
{ type: "text", text: "New" },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
describe("unseenTimelineParts", () => {
|
||||
test("includes parts appended to an existing assistant message, not its already seen parts", () => {
|
||||
expect([
|
||||
...unseenTimelineParts(messages, { messageID: "msg_answer", created: 2, partID: "msg_answer:text:0" }),
|
||||
]).toEqual(["msg_answer:text:1"])
|
||||
})
|
||||
|
||||
test("uses identity rather than timestamps when messages share a millisecond", () => {
|
||||
expect([
|
||||
...unseenTimelineParts(
|
||||
messages.map((message) => ({ ...message, time: { created: 2 } })),
|
||||
{ messageID: "msg_user", created: 2 },
|
||||
),
|
||||
]).toEqual(["msg_answer:text:0", "msg_answer:text:1"])
|
||||
})
|
||||
|
||||
test("does not mark older parts when the saved part is absent from a refreshed message", () => {
|
||||
expect([
|
||||
...unseenTimelineParts(messages, { messageID: "msg_answer", created: 2, partID: "msg_answer:text:2" }),
|
||||
]).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores earlier paged history when the cursor is outside the loaded window", () => {
|
||||
expect([...unseenTimelineParts(messages, { messageID: "msg_evicted", created: 3 })]).toEqual([])
|
||||
expect([...unseenTimelineParts(messages.slice(1), { messageID: "msg_evicted", created: 1 })]).toEqual([
|
||||
"msg_answer:text:0",
|
||||
"msg_answer:text:1",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { Timeline } from "@opencode/session-ui/timeline/projection"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createComputed, createMemo, on, untrack, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
|
||||
const cursorSchema = Schema.Struct({
|
||||
messageID: Schema.String,
|
||||
created: Schema.Number,
|
||||
partID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
type Cursor = typeof cursorSchema.Type
|
||||
|
||||
const emptyParts: ReadonlySet<string> = new Set()
|
||||
|
||||
export function unseenTimelineParts(messages: SessionMessageInfo[], cursor: Cursor | null) {
|
||||
if (!cursor) return emptyParts
|
||||
const seen = messages.findIndex((message) => message.id === cursor.messageID)
|
||||
|
||||
return new Set(
|
||||
messages.flatMap((message, index) => {
|
||||
if (message.type !== "assistant" || index < seen) return []
|
||||
const parts = Timeline.contentEntries(message)
|
||||
|
||||
if (seen < 0) return message.time.created > cursor.created ? parts.map((part) => part.id) : []
|
||||
|
||||
if (index > seen) return parts.map((part) => part.id)
|
||||
const part = parts.findIndex((part) => part.id === cursor.partID)
|
||||
|
||||
if (cursor.partID && part < 0) return []
|
||||
|
||||
return parts.slice(part + 1).map((part) => part.id)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function createNewMessages(input: {
|
||||
scope: ServerScope
|
||||
directory: string
|
||||
sessionID: string
|
||||
messages: Accessor<SessionMessageInfo[]>
|
||||
active: Accessor<boolean>
|
||||
enabled: Accessor<boolean>
|
||||
caughtUp: Accessor<boolean>
|
||||
pendingInputIDs: Accessor<ReadonlySet<string>>
|
||||
}) {
|
||||
const [saved, setSaved, , ready] = persisted(
|
||||
Persist.serverSession(input.scope, input.directory, input.sessionID, "timeline-seen"),
|
||||
Persistence.struct({ cursor: Schema.NullOr(cursorSchema) }),
|
||||
{ cursor: null },
|
||||
)
|
||||
|
||||
const [state, setState] = createStore<{
|
||||
focused: boolean
|
||||
phase: "idle" | "unread" | "read"
|
||||
retained: ReadonlySet<string>
|
||||
baseline: Cursor | null
|
||||
}>({
|
||||
focused: document.hasFocus() && document.visibilityState !== "hidden",
|
||||
phase: "idle",
|
||||
retained: emptyParts,
|
||||
baseline: null,
|
||||
})
|
||||
|
||||
const focus = () => setState("focused", document.hasFocus() && document.visibilityState !== "hidden")
|
||||
makeEventListener(window, "focus", focus)
|
||||
makeEventListener(window, "blur", focus)
|
||||
makeEventListener(document, "visibilitychange", focus)
|
||||
|
||||
// Cached rows can render before the selected transcript finishes refreshing.
|
||||
const watching = createMemo(() => input.active() && state.focused && ready() && input.caughtUp())
|
||||
|
||||
// Pending steers are displayed after live work, not at its read boundary.
|
||||
const messages = createMemo(() => {
|
||||
const pending = input.pendingInputIDs()
|
||||
|
||||
return pending.size ? input.messages().filter((message) => !pending.has(message.id)) : input.messages()
|
||||
})
|
||||
|
||||
const unseen = createMemo(() =>
|
||||
watching() && state.phase === "unread" ? unseenTimelineParts(messages(), state.baseline) : emptyParts,
|
||||
)
|
||||
|
||||
createComputed(() => {
|
||||
const parts = unseen()
|
||||
|
||||
if (parts.size) setState("retained", parts)
|
||||
})
|
||||
|
||||
const read = () => {
|
||||
if (!watching()) return
|
||||
const message = messages().at(-1)
|
||||
|
||||
if (!message) return
|
||||
const partID = message.type === "assistant" ? Timeline.contentEntries(message).at(-1)?.id : undefined
|
||||
|
||||
setState("phase", "read")
|
||||
|
||||
if (saved.cursor?.messageID !== message.id || saved.cursor?.partID !== partID)
|
||||
setSaved("cursor", { messageID: message.id, created: message.time.created, partID })
|
||||
}
|
||||
|
||||
createComputed(
|
||||
on(watching, (watching) => {
|
||||
if (!watching) return
|
||||
|
||||
setState({
|
||||
baseline: saved.cursor ? { ...saved.cursor } : null,
|
||||
phase: saved.cursor ? "unread" : "idle",
|
||||
retained: emptyParts,
|
||||
})
|
||||
|
||||
if (unseen().size === 0) read()
|
||||
}),
|
||||
)
|
||||
|
||||
createComputed(() => {
|
||||
if (watching() && !input.enabled()) untrack(read)
|
||||
})
|
||||
|
||||
return {
|
||||
parts: createMemo(() => {
|
||||
if (!watching()) return emptyParts
|
||||
|
||||
if (state.phase === "read") return state.retained
|
||||
|
||||
return input.enabled() ? unseen() : emptyParts
|
||||
}),
|
||||
dismissed: () => state.phase === "read",
|
||||
read,
|
||||
release: () => setState("retained", emptyParts),
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,6 @@ type Input = {
|
||||
bottomInset?: Accessor<number>
|
||||
setVisibleRef?: (element: HTMLDivElement | undefined) => void
|
||||
onScheduleScrollState: (element: HTMLDivElement) => void
|
||||
onReadEnd?: () => void
|
||||
onReadMarkPassed?: () => void
|
||||
onUserScroll: (target?: EventTarget | null) => void
|
||||
onHistoryScroll: () => void
|
||||
canRenderImmediately?: (
|
||||
@@ -111,7 +109,7 @@ type Input = {
|
||||
) => boolean
|
||||
setRevealMessage?: (fn: (id: string, partID?: string) => void) => void
|
||||
setRevealPart?: (fn: (target: string) => boolean) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior, read?: boolean) => void) => void
|
||||
setScrollToEnd?: (fn: (behavior?: ScrollBehavior) => void) => void
|
||||
/** Pages the timeline for keys pressed outside it, such as PageUp in the composer. */
|
||||
setPage?: (fn: (direction: 1 | -1) => void) => void
|
||||
/** A tool call the route asks this timeline to open on; `done` clears it once revealed. */
|
||||
@@ -253,77 +251,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const touchDragEvent = { type: "touch.drag" as const, overflow: false }
|
||||
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
|
||||
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
|
||||
let landedNewMessages: string | undefined
|
||||
let readIntent = false
|
||||
|
||||
const newMessagesRow = createMemo(() => rows().find((row) => Predicate.isTagged(row, "NewMessages")))
|
||||
|
||||
const newMessagesKey = createMemo(() => {
|
||||
const row = newMessagesRow()
|
||||
|
||||
return row && !row.read ? TimelineRow.key(row) : undefined
|
||||
})
|
||||
|
||||
const releaseReadMark = () => {
|
||||
const row = newMessagesRow()
|
||||
|
||||
const root = listRoot()
|
||||
|
||||
if (!active() || !row?.read || !root?.isConnected) return
|
||||
const item = virtualizer.measurementsCache[rowKeys().indexOf(TimelineRow.key(row))]
|
||||
|
||||
// Keep a dismissed notice's height until it leaves the reading area.
|
||||
if (
|
||||
item &&
|
||||
(item.end <= root.scrollTop + virtualizer.options.scrollPaddingStart ||
|
||||
item.start >= root.scrollTop + root.clientHeight - bottomInset())
|
||||
)
|
||||
input.onReadMarkPassed?.()
|
||||
}
|
||||
|
||||
const readEnd = () => {
|
||||
const root = listRoot()
|
||||
|
||||
if (!active() || untrack(coldHidden) || !root || (newMessagesKey() && !readIntent) || !atReachableEnd(root)) return
|
||||
input.onReadEnd?.()
|
||||
releaseReadMark()
|
||||
}
|
||||
|
||||
const landNewMessages = () => {
|
||||
const key = newMessagesKey()
|
||||
|
||||
const root = listRoot()
|
||||
|
||||
const state = input.follow.peek()
|
||||
|
||||
// A queued return to the end reads the notice once it gets there.
|
||||
if (untrack(coldHidden) || !key || key === landedNewMessages || !root?.isConnected || state.type === "returning")
|
||||
return false
|
||||
|
||||
if (
|
||||
state.type === "detached" ||
|
||||
(state.type === "anchored" && state.anchor.partID) ||
|
||||
location.hash ||
|
||||
input.reveal?.target()
|
||||
) {
|
||||
landedNewMessages = key
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// A list that does not overflow already shows the reply; wait for its measured height.
|
||||
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
|
||||
if (root.scrollHeight - root.clientHeight <= MIN_OVERFLOW) return false
|
||||
landedNewMessages = key
|
||||
readIntent = false
|
||||
prepareNavigation()
|
||||
input.follow.dispatch({ type: "anchor", anchor: { key } })
|
||||
virtualizer.scrollToIndex(rowKeys().indexOf(key), { align: "start" })
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const [headerHeight, setHeaderHeight] = createSignal(48)
|
||||
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
@@ -378,9 +305,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
)
|
||||
})
|
||||
dispatchCold(CHECK_REQUESTED)
|
||||
|
||||
if (!scrolling) readEnd()
|
||||
releaseReadMark()
|
||||
}
|
||||
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
@@ -508,16 +432,11 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (untrack(coldHidden)) pinColdBottom()
|
||||
dispatchCold(CHECK_REQUESTED)
|
||||
|
||||
if (untrack(coldHidden)) return
|
||||
|
||||
landNewMessages()
|
||||
|
||||
if (!input.follow.following()) return
|
||||
if (untrack(coldHidden) || !input.follow.following()) return
|
||||
const root = listRoot()
|
||||
|
||||
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
|
||||
if (root && !atReachableEnd(root)) virtualizer.scrollToEnd()
|
||||
readEnd()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -599,11 +518,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
|
||||
if (!anchor || !root) return
|
||||
|
||||
const element = virtualContent?.querySelector<HTMLElement>(
|
||||
anchor.partID
|
||||
? `[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`
|
||||
: `[data-timeline-key="${CSS.escape(anchor.key)}"]`,
|
||||
)
|
||||
const element = virtualContent?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(anchor.partID)}"]`)
|
||||
|
||||
if (!element || !active()) return input.follow.dispatch({ type: "release" })
|
||||
|
||||
@@ -614,9 +529,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
root.scrollTop +
|
||||
element.getBoundingClientRect().top -
|
||||
root.getBoundingClientRect().top -
|
||||
(anchor.partID
|
||||
? parseFloat(getComputedStyle(element).scrollMarginTop)
|
||||
: virtualizer.options.scrollPaddingStart),
|
||||
parseFloat(getComputedStyle(element).scrollMarginTop),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -651,12 +564,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => String(item.key)))
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
landedNewMessages = undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!active()) return
|
||||
const root = listRoot()
|
||||
|
||||
if (root) input.setScrollRef(root)
|
||||
@@ -671,11 +579,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const target = input.reveal?.target()
|
||||
|
||||
if (target && revealPart(target)) input.reveal?.done()
|
||||
else if (!landNewMessages() && input.follow.following()) virtualizer.scrollToEnd()
|
||||
else if (input.follow.following()) virtualizer.scrollToEnd()
|
||||
|
||||
reportOffset?.(root.scrollTop, false)
|
||||
dispatchCold(CHECK_REQUESTED)
|
||||
readEnd()
|
||||
})
|
||||
input.setRevealPart?.(revealPart)
|
||||
input.setRevealMessage?.((id, partID) => {
|
||||
@@ -697,20 +604,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
prepareNavigation()
|
||||
virtualizer.scrollToIndex(index, { align: partIndex >= 0 ? "center" : "start" })
|
||||
})
|
||||
input.setScrollToEnd?.((behavior, read) => {
|
||||
input.setScrollToEnd?.((behavior) => {
|
||||
const root = listRoot()
|
||||
|
||||
if (!active() || !root?.isConnected) return
|
||||
|
||||
if (read) readIntent = true
|
||||
|
||||
// Composer measurement also requests the end. It must not override an unread arrival.
|
||||
if (newMessagesKey() && !readIntent && !location.hash && !input.reveal?.target()) {
|
||||
landNewMessages()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
flushTouchAdjustment()
|
||||
input.follow.dispatch({ type: "follow" })
|
||||
|
||||
@@ -718,7 +615,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (behavior === "smooth")
|
||||
virtualizer.scrollToOffset(Math.max(root.scrollTop, root.scrollHeight - root.clientHeight * 2))
|
||||
virtualizer.scrollToEnd({ behavior })
|
||||
readEnd()
|
||||
})
|
||||
input.setPage?.((direction) => {
|
||||
const root = listRoot()
|
||||
@@ -727,31 +623,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
prepareNavigation()
|
||||
input.onUserScroll(root)
|
||||
dispatchIntent(direction < 0)
|
||||
readIntent = true
|
||||
// Like the timeline's own PageUp: 80% of a page, measured above the composer that covers its bottom. The scroll
|
||||
// event then pins at the end or loads older history near the top, as a wheel scroll does.
|
||||
virtualizer.scrollToOffset(root.scrollTop + direction * (visibleArea()?.clientHeight || root.clientHeight) * 0.8)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const key = newMessagesKey()
|
||||
|
||||
const anchor = input.follow.anchor()
|
||||
|
||||
// A held notice moves to the newest unread mark, or lets go once it is read. Every cached view reads the selected
|
||||
// session's follow state, so only the active one may change it.
|
||||
if (active() && anchor && !anchor.partID && anchor.key !== key)
|
||||
input.follow.dispatch(key ? { type: "anchor", anchor: { key } } : { type: "release" })
|
||||
|
||||
if (active())
|
||||
queueMicrotask(() => {
|
||||
if (!active()) return
|
||||
landNewMessages()
|
||||
releaseReadMark()
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
bottomInset,
|
||||
@@ -886,13 +763,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
return
|
||||
}
|
||||
|
||||
if (effect.type === "reveal") {
|
||||
landNewMessages()
|
||||
readEnd()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(revealTimer)
|
||||
contentObserver?.disconnect()
|
||||
viewportObserver?.disconnect()
|
||||
@@ -976,17 +846,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
const handleListWheel = (event: WheelEvent) => {
|
||||
readIntent = true
|
||||
|
||||
if (!active()) return
|
||||
input.onUserScroll(event.target)
|
||||
dispatchIntent(event.deltaY < 0)
|
||||
|
||||
if (event.deltaY > 0) readEnd()
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
readIntent = true
|
||||
clearTouchTarget()
|
||||
|
||||
if (!active()) return
|
||||
@@ -1049,8 +914,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
// Not the secondary button: it opens a context menu that can swallow its release. The middle button stays, as its
|
||||
// drag autoscrolls on Windows and Linux.
|
||||
const handleListPointerDown = (event: PointerEvent) => {
|
||||
readIntent = true
|
||||
|
||||
if (!active() || event.button === 2) return
|
||||
input.onUserScroll(event.target)
|
||||
input.follow.dispatch(pointerDownEvent)
|
||||
@@ -1085,7 +948,6 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
readIntent = true
|
||||
input.onUserScroll(event.currentTarget)
|
||||
dispatchIntent(upwardKeys.has(key))
|
||||
}
|
||||
|
||||
@@ -79,20 +79,6 @@ export const SettingsExperimental: Component = () => {
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={language.t("settings.timeline.row.newMessagesDivider.title")}
|
||||
description={language.t("settings.timeline.row.newMessagesDivider.description")}
|
||||
>
|
||||
<div data-action="settings-new-messages-divider">
|
||||
<Switch
|
||||
checked={settings.general.newMessagesDivider()}
|
||||
onChange={settings.general.setNewMessagesDivider}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.timeline.row.newMessagesDivider.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +93,6 @@ describe("settings schema", () => {
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
newMessagesDivider: true,
|
||||
backgroundActivity: "left",
|
||||
timelineWidth: "comfortable",
|
||||
newIcons: false,
|
||||
|
||||
@@ -125,7 +125,6 @@ const generalSchema = Persistence.struct({
|
||||
}),
|
||||
showCustomAgents: Schema.Boolean,
|
||||
showTurnDiffs: Schema.Boolean,
|
||||
newMessagesDivider: Schema.Boolean,
|
||||
backgroundActivity: Schema.Literals(["off", "left", "center", "right"]),
|
||||
timelineWidth: Schema.Literals(["focused", "comfortable", "wide"]),
|
||||
// The "New icons" design experiment, off until it ships.
|
||||
@@ -303,7 +302,6 @@ export const defaultSettings: Settings = {
|
||||
timelineDetail: { ...timelinePresets[2].value },
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
newMessagesDivider: true,
|
||||
backgroundActivity: "left",
|
||||
timelineWidth: "comfortable",
|
||||
newIcons: false,
|
||||
@@ -405,13 +403,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowTurnDiffs(value: boolean) {
|
||||
setStore("general", "showTurnDiffs", value)
|
||||
},
|
||||
newMessagesDivider: withFallback(
|
||||
() => store.general?.newMessagesDivider,
|
||||
defaultSettings.general.newMessagesDivider,
|
||||
),
|
||||
setNewMessagesDivider(value: boolean) {
|
||||
setStore("general", "newMessagesDivider", value)
|
||||
},
|
||||
backgroundActivity: withFallback(
|
||||
() => store.general?.backgroundActivity,
|
||||
defaultSettings.general.backgroundActivity,
|
||||
|
||||
@@ -125,13 +125,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
description: "settings.experimental.row.newQuestionUI.description",
|
||||
keywords: "question answers keyboard dock redesign",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.timeline.row.newMessagesDivider.title",
|
||||
description: "settings.timeline.row.newMessagesDivider.description",
|
||||
target: "settings-new-messages-divider",
|
||||
keywords: "unread new messages divider return session",
|
||||
},
|
||||
{
|
||||
tab: "experimental",
|
||||
label: "settings.experimental.row.sidebarOrganize.title",
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Predicate } from "effect"
|
||||
import {
|
||||
createTimelineProjection,
|
||||
reasoningHeading,
|
||||
@@ -255,59 +254,6 @@ describe("reuseTimelineRows", () => {
|
||||
})
|
||||
|
||||
describe("createTimelineProjection", () => {
|
||||
test("keeps new tools folded into seen work above the new-messages notice", () => {
|
||||
const messages = [
|
||||
{ id: "user", type: "user", text: "Read files", time: { created: 1 } },
|
||||
{
|
||||
id: "assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
...["seen", "new"].map(
|
||||
(id): SessionMessageAssistantTool => ({
|
||||
type: "tool",
|
||||
id,
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: `${id}.ts` },
|
||||
content: [{ type: "text", text: id }],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 2, completed: 3 },
|
||||
}),
|
||||
),
|
||||
{ type: "text", text: "The result" },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const folded = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
reasoningMode: "full",
|
||||
newMessages: new Set(["new"]),
|
||||
})
|
||||
|
||||
expect(folded.rows.some((row) => Predicate.isTagged(row, "NewMessages"))).toBe(false)
|
||||
|
||||
const result = createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: { type: "idle" },
|
||||
reasoningMode: "full",
|
||||
newMessages: new Set(["new", "assistant:text:0"]),
|
||||
})
|
||||
|
||||
const index = result.rows.findIndex((row) => Predicate.isTagged(row, "NewMessages"))
|
||||
|
||||
expect(result.rows[index]).toEqual(
|
||||
new TimelineRow.NewMessages({ userMessageID: "user", partID: "assistant:text:0", read: false }),
|
||||
)
|
||||
expect(result.rows[index + 1]).toEqual(folded.rows[index])
|
||||
})
|
||||
|
||||
test("builds current message, parent, context, and row indexes", () => {
|
||||
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
|
||||
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
|
||||
|
||||
@@ -93,8 +93,6 @@ export type TimelineProjectionInput = {
|
||||
queuedCompactionIDs?: readonly string[]
|
||||
turnDiffs?: boolean
|
||||
previousRows?: TimelineRow.TimelineRow[]
|
||||
newMessages?: ReadonlySet<string>
|
||||
newMessagesRead?: boolean
|
||||
}
|
||||
|
||||
export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
@@ -113,11 +111,7 @@ export function createTimelineProjection(input: TimelineProjectionInput) {
|
||||
input.turnDiffs,
|
||||
)
|
||||
|
||||
const rows = reuseTimelineRows(
|
||||
input.previousRows,
|
||||
withNewMessages(projection.rows, input.newMessages, input.newMessagesRead),
|
||||
)
|
||||
|
||||
const rows = reuseTimelineRows(input.previousRows, projection.rows)
|
||||
const rowByKey = new Map(rows.map((row) => [TimelineRow.key(row), row] as const))
|
||||
const messageRowIndex = new Map<string, number>()
|
||||
const messageLastRowIndex = new Map<string, number>()
|
||||
@@ -157,8 +151,6 @@ export function createReactiveTimelineProjection(input: {
|
||||
queuedCompactionIDs?: Accessor<readonly string[]>
|
||||
/** Summarize each finished turn's changed files after it. */
|
||||
turnDiffs?: Accessor<boolean>
|
||||
newMessages?: Accessor<ReadonlySet<string>>
|
||||
newMessagesRead?: Accessor<boolean>
|
||||
}) {
|
||||
const sessionMessageByID = createMemo(
|
||||
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
|
||||
@@ -223,7 +215,7 @@ export function createReactiveTimelineProjection(input: {
|
||||
})
|
||||
|
||||
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
|
||||
reuseTimelineRows(previous, withNewMessages(projection().rows, input.newMessages?.(), input.newMessagesRead?.())),
|
||||
reuseTimelineRows(previous, projection().rows),
|
||||
)
|
||||
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const)))
|
||||
@@ -270,30 +262,6 @@ export function createReactiveTimelineProjection(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function withNewMessages(rows: TimelineRow.TimelineRow[], parts?: ReadonlySet<string>, read = false) {
|
||||
if (!parts?.size) return rows
|
||||
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (!Predicate.isTagged(row, "Thinking") && !Predicate.isTagged(row, "AssistantPart")) continue
|
||||
|
||||
const first = Predicate.isTagged(row, "Thinking")
|
||||
? row.ref
|
||||
: row.group.type === "part"
|
||||
? row.group.ref
|
||||
: row.group.refs[0]
|
||||
|
||||
if (!first || !parts.has(first.partID)) continue
|
||||
|
||||
return [
|
||||
...rows.slice(0, index),
|
||||
new TimelineRow.NewMessages({ userMessageID: row.userMessageID, partID: first.partID, read }),
|
||||
...rows.slice(index),
|
||||
]
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export namespace Timeline {
|
||||
export function constructSessionMessageRows(
|
||||
messages: SessionMessageInfo[],
|
||||
|
||||
@@ -1029,29 +1029,6 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
)
|
||||
}
|
||||
|
||||
if (Predicate.isTagged(row(), "NewMessages")) {
|
||||
const current = () => {
|
||||
const value = row()
|
||||
|
||||
if (!Predicate.isTagged(value, "NewMessages")) throw new Error("Expected a new-messages timeline row")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame row={current()}>
|
||||
<div
|
||||
data-slot="session-timeline-notice"
|
||||
aria-hidden={current().read}
|
||||
class={`w-full py-2 ${padding()}`}
|
||||
classList={{ invisible: current().read }}
|
||||
>
|
||||
<TimelineSeparator label={i18n.t("ui.sessionTimeline.notice.newMessages")} />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
if (Predicate.isTagged(row(), "TurnDivider")) {
|
||||
const current = () => {
|
||||
const value = row()
|
||||
|
||||
@@ -45,12 +45,6 @@ export namespace TimelineRow {
|
||||
userMessageID: string
|
||||
}> {}
|
||||
|
||||
export class NewMessages extends Data.TaggedClass("NewMessages")<{
|
||||
userMessageID: string
|
||||
partID: string
|
||||
read: boolean
|
||||
}> {}
|
||||
|
||||
export class AssistantPart extends Data.TaggedClass("AssistantPart")<{
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
@@ -114,7 +108,6 @@ export namespace TimelineRow {
|
||||
| Shell
|
||||
| Notice
|
||||
| TurnDivider
|
||||
| NewMessages
|
||||
| AssistantPart
|
||||
| Thinking
|
||||
| Error
|
||||
@@ -129,7 +122,6 @@ export namespace TimelineRow {
|
||||
Shell: (row) => `shell:${row.messageID}`,
|
||||
Notice: (row) => `notice:${row.messageID}`,
|
||||
TurnDivider: (row) => `turn-divider:${row.userMessageID}`,
|
||||
NewMessages: (row) => `new-messages:${row.partID}`,
|
||||
// Keyed by part identity alone: a page boundary can truncate the leading turn,
|
||||
// and its rows regroup under the real user message once older history loads.
|
||||
// The group key already carries the owning message and part IDs.
|
||||
@@ -152,8 +144,6 @@ export namespace TimelineRow {
|
||||
Shell: (row) => (other: TimelineRow) => Predicate.isTagged(other, "Shell") && other.messageID === row.messageID,
|
||||
Notice: (row) => (other: TimelineRow) => Predicate.isTagged(other, "Notice") && other.messageID === row.messageID,
|
||||
TurnDivider: () => (other: TimelineRow) => Predicate.isTagged(other, "TurnDivider"),
|
||||
NewMessages: (row) => (other: TimelineRow) =>
|
||||
Predicate.isTagged(other, "NewMessages") && other.partID === row.partID && other.read === row.read,
|
||||
AssistantPart: (row) => (other: TimelineRow) =>
|
||||
Predicate.isTagged(other, "AssistantPart") &&
|
||||
other.previousAssistantPart === row.previousAssistantPart &&
|
||||
@@ -212,7 +202,6 @@ export type TimelineRowMap = {
|
||||
Shell: { userMessageID: string; messageID: string }
|
||||
Notice: { userMessageID: string; messageID: string }
|
||||
TurnDivider: { userMessageID: string }
|
||||
NewMessages: { userMessageID: string; partID: string; read: boolean }
|
||||
AssistantPart: {
|
||||
userMessageID: string
|
||||
group: PartGroup
|
||||
|
||||
@@ -312,7 +312,6 @@ const source = {
|
||||
"ui.sources.openFailed": "Couldn’t open page",
|
||||
"ui.sessionTimeline.notice.model": "Model",
|
||||
"ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}",
|
||||
"ui.sessionTimeline.notice.newMessages": "New",
|
||||
"ui.sessionTimeline.notice.variantSwitched": "Switched variant to {{variant}}",
|
||||
"ui.sessionTimeline.notice.variantDefault": "Switched variant to Default",
|
||||
"ui.sessionTimeline.notice.agentChanged": "Agent changed",
|
||||
|
||||
Reference in New Issue
Block a user