Skip to content

Commit 651032b

Browse files
authored
feat(app): fold long user messages behind an experimental setting (#59435)
1 parent 4585c31 commit 651032b

15 files changed

Lines changed: 598 additions & 6 deletions

File tree

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

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,9 @@ test("keeps a file diff anchored while it expands and collapses", async ({ page
196196
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
197197
},
198198
messages: [
199-
userMessage([userText("Preceding context ".repeat(120))]),
199+
userMessage(),
200200
assistantMessage([
201+
textPart("prt_file_projection_preceding", "Preceding context ".repeat(240)),
201202
toolPart(
202203
id,
203204
"patch",
@@ -754,3 +755,168 @@ test("groups consecutive sent prompts under the last one's meta line, with each
754755
await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1")
755756
await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1)
756757
})
758+
759+
test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => {
760+
const text = Array.from({ length: 20 }, (_, index) => `Long line ${index + 1}`).join("\n")
761+
762+
await setupTimeline(page, {
763+
messages: [
764+
userMessage([userText(text, { id: "prt_msg_0001_nofold_text" })], {
765+
id: "msg_0001_nofold",
766+
created: 1700000000000,
767+
}),
768+
],
769+
viewport: { width: 1200, height: 700 },
770+
})
771+
772+
const row = page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_0001_nofold"]')
773+
await expect(row).toContainText("Long line 20")
774+
await expect(row.locator('[data-slot="user-message-fold"]')).toHaveCount(0)
775+
await expect(row.getByRole("button", { name: "Read more" })).toHaveCount(0)
776+
})
777+
778+
test("folds a prompt past 12 lines to its first 8 and keeps it open after scrolling out of view", async ({ page }) => {
779+
const lines = (name: string, count: number) =>
780+
Array.from({ length: count }, (_, index) => `${name} line ${index + 1}`).join("\n")
781+
782+
const prompt = (id: string, text: string, created: number) =>
783+
userMessage([userText(text, { id: `prt_${id}_text` })], { id, created })
784+
785+
const reply = (id: string, parentID: string, created: number) =>
786+
assistantMessage(
787+
[{ id: `prt_${id}_text`, type: "text", text: `Reply ${id}. ${"Later work fills the timeline. ".repeat(12)}` }],
788+
{ id, parentID, created },
789+
)
790+
791+
await setupTimeline(page, {
792+
messages: [
793+
prompt("msg_0001_fold_edge", lines("Edge", 12), 1700000000000),
794+
reply("msg_0002_fold_edge", "msg_0001_fold_edge", 1700000001000),
795+
prompt("msg_0003_fold_long", lines("Long", 13), 1700000002000),
796+
reply("msg_0004_fold_long", "msg_0003_fold_long", 1700000003000),
797+
...Array.from({ length: 16 }, (_, index) => {
798+
const created = 1700000010000 + index * 2_000
799+
const id = `msg_${String(100 + index * 2).padStart(4, "0")}_fold_later`
800+
801+
return [prompt(id, `Later prompt ${index}`, created), reply(`${id}_reply`, id, created + 1_000)]
802+
}).flat(),
803+
],
804+
settings: { foldUserMessages: true },
805+
viewport: { width: 1200, height: 700 },
806+
})
807+
808+
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
809+
810+
const row = (id: string) =>
811+
page.locator(`[data-timeline-virtual-content] [data-timeline-row="UserMessage"][data-message-id="${id}"]`)
812+
813+
const edge = row("msg_0001_fold_edge")
814+
const long = row("msg_0003_fold_long")
815+
const bubble = long.locator('[data-slot="user-message-text"]')
816+
const fold = long.locator('[data-slot="user-message-fold"]')
817+
const lastLater = row("msg_0130_fold_later")
818+
await expect(lastLater).toBeVisible()
819+
820+
// A wheel, not a scroll write, so the timeline stops following its end.
821+
await scroller.hover()
822+
await page.mouse.wheel(0, -100_000)
823+
await expect(long).toBeVisible()
824+
825+
// 12 lines show in full; 13 fold to 8, and all 13 stay in the text for copy and select-all.
826+
await expect(edge.locator('[data-slot="user-message-fold"]')).toContainText("Edge line 12")
827+
await expect(edge.getByRole("button", { name: "Read more" })).toHaveCount(0)
828+
const toggle = long.getByRole("button", { name: /^(Read more|Show less)$/ })
829+
await expect(toggle).toHaveAccessibleName("Read more")
830+
await expect(toggle).toHaveAttribute("aria-expanded", "false")
831+
const lineHeight = await fold.evaluate((element) => parseFloat(getComputedStyle(element).lineHeight))
832+
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(8)
833+
expect(
834+
await fold.evaluate((element) => {
835+
const selection = getSelection()!
836+
selection.selectAllChildren(element)
837+
const text = selection.toString()
838+
selection.removeAllRanges()
839+
840+
return text
841+
}),
842+
).toContain("Long line 13")
843+
844+
// A cached tab's timeline is detached while hidden; the message stays folded rather than measuring 0 lines.
845+
expect(
846+
await bubble.evaluate(async (element) => {
847+
const parent = element.parentElement!
848+
const next = element.nextSibling
849+
element.remove()
850+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
851+
const folded = element.querySelector('[data-slot="user-message-fold"]')!.hasAttribute("data-folded")
852+
parent.insertBefore(element, next)
853+
854+
return folded
855+
}),
856+
).toBe(true)
857+
await expect(toggle).toHaveAttribute("aria-expanded", "false")
858+
859+
// Opening grows the bubble downward, and the virtual row takes its new height.
860+
await bubble.evaluate((element) => element.scrollIntoView({ block: "center" }))
861+
const top = (await bubble.boundingBox())!.y
862+
await toggle.click()
863+
await expect(toggle).toHaveAttribute("aria-expanded", "true")
864+
await expect(toggle).toHaveAccessibleName("Show less")
865+
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13)
866+
expect(Math.abs((await bubble.boundingBox())!.y - top)).toBeLessThanOrEqual(1)
867+
await expect
868+
.poll(() =>
869+
long.evaluate((element) => {
870+
const allocated = element.closest<HTMLElement>("[data-timeline-key]")!.getBoundingClientRect().height
871+
const content = element.closest<HTMLElement>("[data-index]")!.getBoundingClientRect().height
872+
873+
return Math.abs(allocated - content)
874+
}),
875+
)
876+
.toBeLessThanOrEqual(1)
877+
878+
// The row unmounts at the end of the timeline and comes back open.
879+
await page.mouse.wheel(0, 100_000)
880+
await expect(lastLater).toBeVisible()
881+
await expect(long).toHaveCount(0)
882+
await page.mouse.wheel(0, -100_000)
883+
await expect(long.getByRole("button", { name: "Show less" })).toHaveAttribute("aria-expanded", "true")
884+
await expect.poll(async () => Math.round((await fold.boundingBox())!.height / lineHeight)).toBe(13)
885+
886+
// Closing a message whose top is on screen shrinks it in place, without scrolling the timeline.
887+
await scroller.evaluate(
888+
(element, target) => {
889+
element.scrollTop += target!.y - element.getBoundingClientRect().top - 100
890+
},
891+
await bubble.boundingBox(),
892+
)
893+
894+
const closing = {
895+
top: (await bubble.boundingBox())!.y,
896+
scroll: await scroller.evaluate((element) => element.scrollTop),
897+
}
898+
899+
expect(closing.scroll).toBeGreaterThan(150)
900+
await toggle.click()
901+
await expect(toggle).toHaveAttribute("aria-expanded", "false")
902+
expect(Math.abs((await bubble.boundingBox())!.y - closing.top)).toBeLessThanOrEqual(1)
903+
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(closing.scroll)
904+
905+
// Find skips the Read more button, and a match hidden in the fold opens the message.
906+
const highlights = () =>
907+
page.evaluate(() =>
908+
["timeline-search-hit", "timeline-search-hit-active"].reduce(
909+
(count, name) => count + (CSS.highlights.get(name)?.size ?? 0),
910+
0,
911+
),
912+
)
913+
914+
await page.keyboard.press("ControlOrMeta+f")
915+
const search = page.locator('[data-component="timeline-search-bar"] input')
916+
await expect(search).toBeFocused()
917+
await search.fill("more")
918+
await expect.poll(highlights).toBe(0)
919+
await search.fill("Long line 13")
920+
await expect(toggle).toHaveAttribute("aria-expanded", "true")
921+
await expect.poll(highlights).toBe(1)
922+
})

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,8 @@ export const dict = {
773773
"settings.experimental.row.newIcons.description": "Preview the updated icon set",
774774
"settings.experimental.row.newModelPicker.title": "New model picker",
775775
"settings.experimental.row.newModelPicker.description": "Preview the redesigned model picker",
776+
"settings.experimental.row.foldUserMessages.title": "Fold long messages",
777+
"settings.experimental.row.foldUserMessages.description": "Fold your messages past 12 lines behind a Read more button",
776778
"settings.plugins.reload": "Reload",
777779
"settings.plugins.status.loading": "Loading",
778780
"settings.plugins.status.failed": "Failed",

‎packages/app/src/session/timeline/message-timeline.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { TextShimmer } from "@opencode/ui/text-shimmer"
1414
import type { BackgroundTask, MountedSession, SessionScreen } from "@opencode/plugin/gui"
1515
import { PluginSlot } from "@/runtime/plugin/render"
1616
import { useLanguage } from "@/runtime/i18n/language"
17+
import { useSettings } from "@/settings/model"
1718
import { useServer } from "@/runtime/server/current"
1819
import { useWorkspaceLocation } from "@/workspaces/location"
1920
import { Timeline } from "@opencode/session-ui/timeline/projection"
@@ -327,7 +328,10 @@ function MessageTimelineView(
327328
setTitle({ draft: "", editing: false, menuOpen: false, pendingRename: false })
328329
})
329330

331+
const settings = useSettings()
332+
330333
const rowRenderer = createSessionTimelineRowRenderer({
334+
foldUserMessages: settings.general.foldUserMessages,
331335
sessionID: () => sessionID()!,
332336
status: sessionStatus,
333337
projection,

‎packages/app/src/session/timeline/search-controller.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ const HIGHLIGHT_ACTIVE = "timeline-search-hit-active"
2020

2121
const TEXT_SELECTORS = '[data-slot="text-part-body"], [data-slot="user-message-text"]'
2222

23+
const FOLD_TOGGLE = '[data-slot="user-message-fold-toggle"]'
24+
25+
// Matches the fold's mask in session-ui's message-part.css.
26+
const FOLD_FADE = 40
27+
2328
function supportsHighlights() {
24-
return typeof CSS !== "undefined" && typeof CSS.highlights === "object" && CSS.highlights !== null
29+
return !!globalThis.CSS?.highlights
2530
}
2631

2732
function clearHighlights() {
@@ -46,7 +51,14 @@ function collectRanges(
4651
const partID = part?.getAttribute("data-timeline-part-id")
4752
const isActivePart = activePartID !== undefined && partID === activePartID
4853
let occurrenceInPart = 0
49-
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT)
54+
55+
// A folded user message's Read more button sits inside its text.
56+
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
57+
acceptNode: (node) =>
58+
node.parentElement?.closest(FOLD_TOGGLE) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
59+
})
60+
61+
// SAFETY: SHOW_TEXT restricts this walker to Text nodes.
5062
let node = walker.nextNode() as Text | null
5163

5264
while (node) {
@@ -67,6 +79,7 @@ function collectRanges(
6779
at = lowerValue.indexOf(lower, from)
6880
}
6981

82+
// SAFETY: SHOW_TEXT restricts this walker to Text nodes.
7083
node = walker.nextNode() as Text | null
7184
}
7285
}
@@ -266,6 +279,24 @@ export function createTimelineSearchController(input: {
266279
}
267280

268281
const rect = active[0].getBoundingClientRect()
282+
283+
const fold = active[0].startContainer.parentElement?.closest<HTMLElement>(
284+
'[data-slot="user-message-fold"][data-folded]',
285+
)
286+
287+
// A match in or past a folded message's fading last lines opens the message, then scrolls to it.
288+
const toggle =
289+
fold && rect.bottom > fold.getBoundingClientRect().bottom - FOLD_FADE
290+
? root.querySelector<HTMLElement>(`${FOLD_TOGGLE}[aria-controls="${CSS.escape(fold.id)}"]`)
291+
: null
292+
293+
if (toggle) {
294+
toggle.click()
295+
requestAnimationFrame(seek)
296+
297+
return
298+
}
299+
269300
const rootRect = root.getBoundingClientRect()
270301
const sticky = root.querySelector("[data-session-title]")
271302
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,11 @@ export function createTimelineVirtualizer(input: Input) {
10461046
disclosure: {
10471047
patchGroupKeys,
10481048
value: (key: string) => toolOpen[key],
1049-
set: (key: string, open: boolean) => setToolOpen(key, open),
1049+
set: (key: string, open: boolean) => {
1050+
// Following the end would scroll an opened user message's top away; it stays where it was pressed.
1051+
if (open && key.startsWith("user-expanded:")) input.onUnpin()
1052+
setToolOpen(key, open)
1053+
},
10501054
},
10511055
revealPart,
10521056
View,

‎packages/app/src/settings/experimental/experimental.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ export const SettingsExperimental: Component = () => {
5050
/>
5151
</div>
5252
</SettingsRow>
53+
<SettingsRow
54+
title={language.t("settings.experimental.row.foldUserMessages.title")}
55+
description={language.t("settings.experimental.row.foldUserMessages.description")}
56+
>
57+
<div data-action="settings-fold-user-messages">
58+
<Switch
59+
checked={settings.general.foldUserMessages()}
60+
onChange={(checked) => settings.general.setFoldUserMessages(checked)}
61+
/>
62+
</div>
63+
</SettingsRow>
5364
</SettingsList>
5465
</div>
5566
</div>

‎packages/app/src/settings/model.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ describe("settings schema", () => {
9797
timelineWidth: "comfortable",
9898
newIcons: false,
9999
newModelPicker: false,
100+
foldUserMessages: false,
100101
mobileTitlebarPosition: "top",
101102
followUpBehavior: "steer",
102103
},

‎packages/app/src/settings/model.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ const generalSchema = Persistence.struct({
131131
newIcons: Schema.Boolean,
132132
// The "New model picker" design experiment, off until it ships.
133133
newModelPicker: Schema.Boolean,
134+
// The "Fold long messages" experiment, off until it ships.
135+
foldUserMessages: Schema.Boolean,
134136
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
135137
// Owned by the review plugin, which copies it out once; kept so settings rewrites cannot drop it first.
136138
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@@ -300,6 +302,7 @@ export const defaultSettings: Settings = {
300302
timelineWidth: "comfortable",
301303
newIcons: false,
302304
newModelPicker: false,
305+
foldUserMessages: false,
303306
mobileTitlebarPosition: "top",
304307
followUpBehavior: "steer",
305308
},
@@ -412,6 +415,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
412415
setNewModelPicker(value: boolean) {
413416
setStore("general", "newModelPicker", value)
414417
},
418+
foldUserMessages: withFallback(() => store.general?.foldUserMessages, defaultSettings.general.foldUserMessages),
419+
setFoldUserMessages(value: boolean) {
420+
setStore("general", "foldUserMessages", value)
421+
},
415422
mobileTitlebarPosition: withFallback(
416423
() => store.general?.mobileTitlebarPosition,
417424
defaultSettings.general.mobileTitlebarPosition,

‎packages/app/src/settings/search-catalog.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
111111
description: "settings.experimental.row.newModelPicker.description",
112112
keywords: "model picker selector switcher redesign",
113113
},
114+
{
115+
tab: "experimental",
116+
label: "settings.experimental.row.foldUserMessages.title",
117+
target: "settings-fold-user-messages",
118+
description: "settings.experimental.row.foldUserMessages.description",
119+
keywords: "fold collapse long messages prompts read more",
120+
},
114121
{
115122
tab: "appearance",
116123
label: "settings.general.row.colorScheme.title",

0 commit comments

Comments
 (0)