Skip to content

Commit 589c62d

Browse files
authored
feat(session-ui): move user message actions into a menu (#60208)
1 parent d9d3548 commit 589c62d

8 files changed

Lines changed: 112 additions & 206 deletions

File tree

‎packages/app/e2e/regression/session-queue.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1256,7 +1256,7 @@ for (const delivery of ["steer", "queue"] as const) {
12561256

12571257
if (colors) {
12581258
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
1259-
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model\s*·\s*Just now$/, {
1259+
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model$/, {
12601260
useInnerText: true,
12611261
})
12621262
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -747,13 +747,13 @@ test("groups consecutive sent prompts under the last one's meta line, with each
747747
return Math.round(below.y - above.y - above.height)
748748
})
749749
.toBe(4)
750-
await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden()
751-
await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible()
750+
await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden()
751+
await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible()
752752

753753
// The first message keeps its own actions, shown beside its bubble on hover.
754754
await bubble(first).hover()
755755
await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1")
756-
await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1)
756+
await expect(first.getByRole("button", { name: "More actions" })).toHaveCount(1)
757757
})
758758

759759
test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => {

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3134,11 +3134,11 @@ test("reaches the quote action from the keyboard and blocks a selection over the
31343134
await page.keyboard.press("Escape")
31353135
await short.evaluate((element) => window.getSelection()?.selectAllChildren(element))
31363136
await expect(toolbar).toBeVisible()
3137-
const copy = page.getByRole("button", { name: "Copy message" })
3138-
await copy.focus()
3137+
const more = page.getByRole("button", { name: "More actions" })
3138+
await more.focus()
31393139
await expect(toolbar).toBeVisible()
31403140
await page.keyboard.press("Tab")
3141-
await expect(copy).not.toBeFocused()
3141+
await expect(more).not.toBeFocused()
31423142
expect(await page.evaluate(() => !!document.activeElement?.closest('[data-component="timeline-quote"]'))).toBe(false)
31433143
await expect(composer).toHaveCount(1)
31443144
})

‎packages/session-ui/component-tests/session-message-projection.spec.ts‎

Lines changed: 38 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,57 @@
11
import { expect, story } from "../../stories/playwright/story"
22

3-
story.describe("message timestamp", () => {
3+
story.describe("message actions menu", () => {
44
story.use({ timezoneId: "UTC" })
55

6-
story("shows the full creation date, time, and time zone on hover and focus", async ({ mount, page }) => {
6+
story("shows the creation time and disables actions the message cannot take", async ({ mount, page }) => {
77
const timeline = await mount("current-prompt-message--pending")
8-
const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]')
9-
await expect(timestamp.locator("time")).toHaveText("Jan 1, 2025")
10-
await timeline.getByLabel("Delivery state").selectOption("delivered")
118
const message = timeline.locator('[data-component="user-message"]')
9+
const more = timeline.getByRole("button", { name: "More actions", exact: true })
10+
const fork = page.getByRole("menuitem", { name: "Fork from here", exact: true })
11+
const copy = page.getByRole("menuitem", { name: "Copy message", exact: true })
12+
13+
// The meta line keeps agent and model; the time moved into the menu.
14+
await expect(timeline.locator('[data-slot="user-message-meta"] time')).toHaveCount(0)
15+
16+
await timeline.getByLabel("Delivery state").selectOption("delivered")
1217
await message.hover()
13-
await expect(timestamp).toHaveCSS("opacity", "1")
14-
await page.clock.install()
15-
await page.clock.pauseAt(new Date(Date.now() + 1000))
16-
await timestamp.locator("time").hover()
17-
const tooltip = page.getByRole("tooltip")
18-
await page.clock.runFor(799)
19-
await expect(tooltip).toBeHidden()
20-
await page.clock.runFor(1)
21-
await expect(tooltip).toHaveText("Wednesday, January 1, 2025 at 12:00:00 AM UTC")
22-
await page.clock.resume()
18+
await expect(timeline.getByRole("button", { name: "Rewind to here", exact: true })).toBeVisible()
19+
await more.click()
20+
const menu = page.getByRole("menu")
21+
await expect(menu.locator("time")).toHaveText("Jan 1, 2025, 12:00 AM")
22+
await expect(fork).toBeEnabled()
23+
await expect(copy).toBeEnabled()
24+
// An open menu keeps the footer in view after the pointer leaves the message.
2325
await page.mouse.move(0, 0)
24-
await expect(tooltip).toBeHidden()
25-
await expect(timestamp).toHaveCSS("opacity", "0")
26-
await timestamp.locator('[data-component="tooltip-v2-trigger"]').focus()
27-
await expect(tooltip).toBeVisible()
26+
await expect(timeline.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1")
27+
await page.keyboard.press("Escape")
28+
await expect(menu).toBeHidden()
2829

30+
// A pending steer cannot fork, and a message without text has nothing to copy.
31+
await timeline.getByLabel("Delivery state").selectOption("steering")
32+
await timeline.getByLabel("Prompt text", { exact: true }).uncheck()
33+
await more.click()
34+
await expect(fork).toHaveAttribute("aria-disabled", "true")
35+
await expect(copy).toHaveAttribute("aria-disabled", "true")
36+
await page.keyboard.press("Escape")
37+
38+
await timeline.getByLabel("Prompt text", { exact: true }).check()
39+
await timeline.getByLabel("Delivery state").selectOption("delivered")
2940
await page.evaluate(() => document.fonts.ready.then(() => undefined))
3041

3142
for (const width of [1000, 390]) {
3243
await page.setViewportSize({ width, height: 844 })
3344
const bubble = await timeline.locator('[data-slot="user-message-text"]').boundingBox()
34-
const time = await timestamp.boundingBox()
45+
const trigger = await more.boundingBox()
3546
const footer = await timeline.locator('[data-slot="user-message-copy-wrapper"]').boundingBox()
36-
const message = await timeline.locator('[data-component="user-message"]').boundingBox()
47+
const box = await message.boundingBox()
3748

38-
if (!bubble || !time || !footer || !message) throw new Error("Message geometry is unavailable")
39-
expect(Math.abs(bubble.x + bubble.width - message.x - message.width)).toBeLessThan(1)
49+
if (!bubble || !trigger || !footer || !box) throw new Error("Message geometry is unavailable")
50+
expect(Math.abs(bubble.x + bubble.width - box.x - box.width)).toBeLessThan(1)
4051
expect(footer.y).toBeGreaterThanOrEqual(bubble.y + bubble.height)
4152
expect(footer.x + footer.width).toBeLessThanOrEqual(bubble.x + bubble.width + 1)
42-
expect(time.x).toBeGreaterThanOrEqual(footer.x)
43-
expect(time.x + time.width).toBeLessThanOrEqual(footer.x + footer.width)
44-
expect(Math.abs(time.y + time.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1)
53+
expect(Math.abs(trigger.x + trigger.width - footer.x - footer.width)).toBeLessThan(1)
54+
expect(Math.abs(trigger.y + trigger.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1)
4555
}
4656
})
4757
})
@@ -50,13 +60,11 @@ story("drops a pending footer at once on delivery with and without hover", async
5060
const timeline = await mount("current-prompt-message--pending")
5161
const message = timeline.locator('[data-component="user-message"]')
5262
const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]')
53-
const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]')
5463
const pending = timeline.locator('[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])')
5564

5665
await page.mouse.move(0, 0)
5766
await expect(footer).toHaveCSS("height", "20px")
58-
await expect(footer).toHaveCSS("margin-top", "4px")
59-
await expect(timestamp).toHaveCSS("opacity", "0")
67+
await expect(footer).toHaveCSS("margin-top", "8px")
6068

6169
for (const hover of [true, false]) {
6270
await timeline.getByLabel("Delivery state").selectOption("steering")
@@ -69,8 +77,6 @@ story("drops a pending footer at once on delivery with and without hover", async
6977
if (hover) await message.hover()
7078

7179
if (!hover) await page.mouse.move(0, 0)
72-
// Pending stands in for the unsent time, so the actions follow the labels without a gap held for it.
73-
await expect(timestamp).toBeHidden()
7480
const label = await timeline.locator('[data-slot="user-message-meta-item"]').last().boundingBox()
7581
const actions = await timeline.locator('[data-slot="user-message-actions"]').boundingBox()
7682

@@ -99,7 +105,6 @@ story("drops a pending footer at once on delivery with and without hover", async
99105

100106
await expect(pending).toBeHidden()
101107
await expect(footer).toHaveCSS("opacity", hover ? "1" : "0")
102-
await expect(timestamp).toHaveCSS("opacity", hover ? "1" : "0")
103108
}
104109

105110
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -145,44 +150,23 @@ story("marks pending metadata without badging delivered, starting, or queued pro
145150
"Claude Sonnet 4",
146151
"Balanced",
147152
])
148-
await expect(timeline.locator('[data-slot="user-message-meta-item"][data-variant]')).toHaveCSS("opacity", "0.66")
149153
const separator = timeline.locator('[data-slot="user-message-meta-label"] [data-slot="user-message-meta-separator"]')
150154
await expect(separator).toHaveText("\u00B7")
151-
await expect(separator).toHaveCSS("opacity", "0.66")
152155
await expect(timeline.locator('[data-slot="user-message-meta-label"]')).toHaveCSS("gap", "4px")
153156

154157
await timeline.getByLabel("Delivery state").selectOption("steering")
155158
await timeline.getByLabel("Voice message", { exact: true }).check()
156159
const voice = timeline.getByRole("img", { name: "Voice", exact: true })
157160
await expect(voice).toBeVisible()
158161
await expect(indicator).toBeVisible()
159-
await expect(indicator).toHaveAttribute("aria-hidden", "true")
162+
await expect(indicator.locator("svg")).toHaveCount(0)
160163

161164
await expect(voice.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-wave-audio")
162-
await expect(indicator.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-steer")
163-
164-
const icon = await indicator.locator("svg").boundingBox()
165-
const label = await timeline.locator('[data-slot="user-message-meta"]').boundingBox()
166-
167-
if (!icon || !label) throw new Error("Pending metadata geometry is unavailable")
168-
expect(Math.abs(icon.y + icon.height / 2 - label.y - label.height / 2)).toBeLessThan(0.5)
169-
170165
await expect(voice).toHaveCSS("width", "20px")
171166
await expect(voice).toHaveCSS("height", "20px")
172167
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px")
173168
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("height", "14px")
174169

175-
const faint = await indicator.evaluate((element) => {
176-
const probe = document.createElement("span")
177-
probe.style.color = "var(--v2-icon-icon-faint)"
178-
element.append(probe)
179-
const color = getComputedStyle(probe).color
180-
probe.remove()
181-
182-
return color
183-
})
184-
185-
await expect(indicator).toHaveCSS("color", faint)
186170
await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending/)
187171
await timeline.getByLabel("Grouped prompts", { exact: true }).check()
188172
await expect(timeline.locator('[data-run-folded] [data-slot="user-message-copy-wrapper"]')).toBeHidden()

‎packages/session-ui/component-tests/session-timeline-mobile.spec.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ story.describe("touch timeline", () => {
88
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
99

1010
for (const action of [
11-
{ slot: "user-message-copy-wrapper", name: "Copy message" },
11+
{ slot: "user-message-copy-wrapper", name: "More actions" },
1212
{ slot: "text-part-copy-wrapper", name: "Copy response" },
1313
]) {
1414
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
@@ -18,10 +18,9 @@ story.describe("touch timeline", () => {
1818
await expect(actions.getByRole("button", { name: action.name, exact: true })).toBeVisible()
1919
}
2020

21-
// Agent and model are omitted on mobile, leaving only the time and duration.
21+
// Agent and model are omitted on mobile, leaving only the response duration.
2222
await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveCount(0)
2323
await expect(timeline.locator('[data-slot="user-message-pending"]')).toBeHidden()
24-
await expect(timeline.locator('[data-slot="user-message-meta-tail"]')).not.toBeEmpty()
2524
const responseMeta = timeline.locator('[data-slot="text-part-meta"]')
2625
await expect(responseMeta).not.toBeEmpty()
2726
await expect(responseMeta).not.toContainText("Build")
@@ -62,7 +61,7 @@ story("desktop message actions still appear on hover and keyboard focus", async
6261
expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(true)
6362

6463
for (const action of [
65-
{ slot: "user-message-copy-wrapper", name: "Copy message" },
64+
{ slot: "user-message-copy-wrapper", name: "More actions" },
6665
{ slot: "text-part-copy-wrapper", name: "Copy response" },
6766
]) {
6867
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
@@ -72,25 +71,29 @@ story("desktop message actions still appear on hover and keyboard focus", async
7271
await actions.locator("..").hover()
7372
await expect(actions).toHaveCSS("opacity", "1")
7473
await expect(actions).toHaveCSS("pointer-events", "auto")
74+
7575
if (action.slot === "text-part-copy-wrapper") {
7676
await expect(actions).toHaveCSS("height", "20px")
77-
await expect(actions).toHaveCSS("margin-top", "4px")
78-
await expect(actions).toHaveCSS("gap", "8px")
77+
await expect(actions).toHaveCSS("margin-top", "8px")
78+
await expect(actions).toHaveCSS("gap", "4px")
7979
const meta = actions.locator('[data-slot="text-part-meta"]')
8080
await expect(meta).toHaveCSS("font-size", "12px")
81-
await expect(meta).toHaveCSS("line-height", "16px")
81+
await expect(meta).toHaveCSS("line-height", "18px")
8282
await expect(meta).toHaveCSS("gap", "4px")
8383
await expect(meta.locator('[data-slot="text-part-meta-item"]')).toHaveText(["Build", "Claude Sonnet 4", "4s"])
8484
const separators = meta.locator('[data-slot="text-part-meta-separator"]')
8585
await expect(separators).toHaveCount(2)
86+
8687
for (const separator of await separators.all()) {
8788
await expect(separator).toHaveText("\u00B7")
88-
await expect(separator).toHaveCSS("opacity", "0.66")
8989
}
90+
9091
const copy = actions.getByRole("button", { name: action.name, exact: true })
9192
await expect(copy).toHaveCSS("width", "20px")
9293
await expect(copy).toHaveCSS("height", "20px")
94+
await expect(copy.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px")
9395
}
96+
9497
await page.mouse.move(0, 0)
9598
await expect(actions).toHaveCSS("opacity", "0")
9699
await actions.getByRole("button", { name: action.name, exact: true }).focus()

‎packages/session-ui/src/components/message-part.css‎

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,6 @@
168168
color: var(--v2-icon-icon-muted);
169169
}
170170

171-
[data-slot="user-message-pending"] {
172-
display: inline-flex;
173-
align-items: center;
174-
justify-content: center;
175-
flex: none;
176-
width: 14px;
177-
height: 16px;
178-
color: var(--v2-icon-icon-faint);
179-
}
180-
181171
[data-slot="user-message-text"] {
182172
display: inline-block;
183173
white-space: pre-wrap;
@@ -280,7 +270,7 @@
280270

281271
[data-slot="user-message-copy-wrapper"] {
282272
height: calc(20px * var(--user-run-meta, 1));
283-
margin-top: calc(4px * var(--user-run-meta, 1));
273+
margin-top: calc(8px * var(--user-run-meta, 1));
284274
display: flex;
285275
align-items: center;
286276
justify-content: flex-end;
@@ -326,24 +316,22 @@
326316
overflow: hidden;
327317
text-overflow: ellipsis;
328318

329-
/* The variant reads as a qualifier of the model before it, so it is dimmer and never the part that truncates. */
319+
/* The variant reads as a qualifier of the model before it, so it is never the part that truncates. */
330320
&[data-variant] {
331321
flex: none;
332-
opacity: 0.66;
333322
}
334323
}
335324

336325
[data-slot="user-message-meta-separator"] {
337326
flex: none;
338-
opacity: 0.66;
339327
}
340328

341329
[data-slot="user-message-meta-wrap"],
342330
[data-slot="user-message-meta-tail"] {
343331
font-family: var(--v2-font-family-sans);
344332
font-size: 12px;
345333
font-weight: 440;
346-
line-height: var(--line-height-compact);
334+
line-height: 18px;
347335
letter-spacing: 0.05px;
348336
color: var(--v2-text-text-faint);
349337
cursor: default;
@@ -504,11 +492,11 @@
504492

505493
[data-slot="text-part-copy-wrapper"] {
506494
height: 20px;
507-
margin-top: 4px;
495+
margin-top: 8px;
508496
display: flex;
509497
align-items: center;
510498
justify-content: flex-start;
511-
gap: 8px;
499+
gap: 4px;
512500

513501
/* Hover actions show and hide at once, without a fade. */
514502
@media (hover: hover) {
@@ -521,6 +509,16 @@
521509
flex: none;
522510
width: fit-content;
523511
}
512+
513+
[data-component="icon-button-v2"] [data-slot="icon-svg"] {
514+
width: 14px;
515+
height: 14px;
516+
}
517+
518+
/* Pulls the glyph toward the reply's text edge. */
519+
[data-component="tooltip-v2-trigger"]:first-child {
520+
margin-inline-start: -2px;
521+
}
524522
}
525523

526524
[data-slot="text-part-meta"] {
@@ -533,7 +531,7 @@
533531
font-family: var(--v2-font-family-sans);
534532
font-size: 12px;
535533
font-weight: 440;
536-
line-height: var(--line-height-compact);
534+
line-height: 18px;
537535
letter-spacing: 0.05px;
538536
color: var(--v2-text-text-faint);
539537
cursor: default;
@@ -547,19 +545,16 @@
547545

548546
&[data-variant] {
549547
flex: none;
550-
opacity: 0.66;
551548
}
552549
}
553550

554551
[data-slot="text-part-meta-separator"] {
555552
flex: none;
556-
opacity: 0.66;
557553
}
558554

559555
[data-slot="text-part-copy-wrapper"][data-interrupted] {
560556
width: 100%;
561557
justify-content: flex-end;
562-
gap: 8px;
563558
}
564559

565560
:root[data-motion="reduce"] & [data-slot="text-part-copy-wrapper"] {

0 commit comments

Comments
 (0)