Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/e2e/regression/session-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ for (const delivery of ["steer", "queue"] as const) {

if (colors) {
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model\s*·\s*Just now$/, {
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model$/, {
useInnerText: true,
})
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)
Expand Down
6 changes: 3 additions & 3 deletions packages/app/e2e/regression/timeline-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,13 +747,13 @@ test("groups consecutive sent prompts under the last one's meta line, with each
return Math.round(below.y - above.y - above.height)
})
.toBe(4)
await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden()
await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible()
await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden()
await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible()

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

test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => {
Expand Down
6 changes: 3 additions & 3 deletions packages/app/e2e/regression/timeline-stream.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3134,11 +3134,11 @@ test("reaches the quote action from the keyboard and blocks a selection over the
await page.keyboard.press("Escape")
await short.evaluate((element) => window.getSelection()?.selectAllChildren(element))
await expect(toolbar).toBeVisible()
const copy = page.getByRole("button", { name: "Copy message" })
await copy.focus()
const more = page.getByRole("button", { name: "More actions" })
await more.focus()
await expect(toolbar).toBeVisible()
await page.keyboard.press("Tab")
await expect(copy).not.toBeFocused()
await expect(more).not.toBeFocused()
expect(await page.evaluate(() => !!document.activeElement?.closest('[data-component="timeline-quote"]'))).toBe(false)
await expect(composer).toHaveCount(1)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,47 +1,57 @@
import { expect, story } from "../../stories/playwright/story"

story.describe("message timestamp", () => {
story.describe("message actions menu", () => {
story.use({ timezoneId: "UTC" })

story("shows the full creation date, time, and time zone on hover and focus", async ({ mount, page }) => {
story("shows the creation time and disables actions the message cannot take", async ({ mount, page }) => {
const timeline = await mount("current-prompt-message--pending")
const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]')
await expect(timestamp.locator("time")).toHaveText("Jan 1, 2025")
await timeline.getByLabel("Delivery state").selectOption("delivered")
const message = timeline.locator('[data-component="user-message"]')
const more = timeline.getByRole("button", { name: "More actions", exact: true })
const fork = page.getByRole("menuitem", { name: "Fork from here", exact: true })
const copy = page.getByRole("menuitem", { name: "Copy message", exact: true })

// The meta line keeps agent and model; the time moved into the menu.
await expect(timeline.locator('[data-slot="user-message-meta"] time')).toHaveCount(0)

await timeline.getByLabel("Delivery state").selectOption("delivered")
await message.hover()
await expect(timestamp).toHaveCSS("opacity", "1")
await page.clock.install()
await page.clock.pauseAt(new Date(Date.now() + 1000))
await timestamp.locator("time").hover()
const tooltip = page.getByRole("tooltip")
await page.clock.runFor(799)
await expect(tooltip).toBeHidden()
await page.clock.runFor(1)
await expect(tooltip).toHaveText("Wednesday, January 1, 2025 at 12:00:00 AM UTC")
await page.clock.resume()
await expect(timeline.getByRole("button", { name: "Rewind to here", exact: true })).toBeVisible()
await more.click()
const menu = page.getByRole("menu")
await expect(menu.locator("time")).toHaveText("Jan 1, 2025, 12:00 AM")
await expect(fork).toBeEnabled()
await expect(copy).toBeEnabled()
// An open menu keeps the footer in view after the pointer leaves the message.
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await expect(timestamp).toHaveCSS("opacity", "0")
await timestamp.locator('[data-component="tooltip-v2-trigger"]').focus()
await expect(tooltip).toBeVisible()
await expect(timeline.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1")
await page.keyboard.press("Escape")
await expect(menu).toBeHidden()

// A pending steer cannot fork, and a message without text has nothing to copy.
await timeline.getByLabel("Delivery state").selectOption("steering")
await timeline.getByLabel("Prompt text", { exact: true }).uncheck()
await more.click()
await expect(fork).toHaveAttribute("aria-disabled", "true")
await expect(copy).toHaveAttribute("aria-disabled", "true")
await page.keyboard.press("Escape")

await timeline.getByLabel("Prompt text", { exact: true }).check()
await timeline.getByLabel("Delivery state").selectOption("delivered")
await page.evaluate(() => document.fonts.ready.then(() => undefined))

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

if (!bubble || !time || !footer || !message) throw new Error("Message geometry is unavailable")
expect(Math.abs(bubble.x + bubble.width - message.x - message.width)).toBeLessThan(1)
if (!bubble || !trigger || !footer || !box) throw new Error("Message geometry is unavailable")
expect(Math.abs(bubble.x + bubble.width - box.x - box.width)).toBeLessThan(1)
expect(footer.y).toBeGreaterThanOrEqual(bubble.y + bubble.height)
expect(footer.x + footer.width).toBeLessThanOrEqual(bubble.x + bubble.width + 1)
expect(time.x).toBeGreaterThanOrEqual(footer.x)
expect(time.x + time.width).toBeLessThanOrEqual(footer.x + footer.width)
expect(Math.abs(time.y + time.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1)
expect(Math.abs(trigger.x + trigger.width - footer.x - footer.width)).toBeLessThan(1)
expect(Math.abs(trigger.y + trigger.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1)
}
})
})
Expand All @@ -50,13 +60,11 @@ story("drops a pending footer at once on delivery with and without hover", async
const timeline = await mount("current-prompt-message--pending")
const message = timeline.locator('[data-component="user-message"]')
const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]')
const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]')
const pending = timeline.locator('[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])')

await page.mouse.move(0, 0)
await expect(footer).toHaveCSS("height", "20px")
await expect(footer).toHaveCSS("margin-top", "4px")
await expect(timestamp).toHaveCSS("opacity", "0")
await expect(footer).toHaveCSS("margin-top", "8px")

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

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

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

await expect(pending).toBeHidden()
await expect(footer).toHaveCSS("opacity", hover ? "1" : "0")
await expect(timestamp).toHaveCSS("opacity", hover ? "1" : "0")
}

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

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

await expect(voice.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-wave-audio")
await expect(indicator.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-steer")

const icon = await indicator.locator("svg").boundingBox()
const label = await timeline.locator('[data-slot="user-message-meta"]').boundingBox()

if (!icon || !label) throw new Error("Pending metadata geometry is unavailable")
expect(Math.abs(icon.y + icon.height / 2 - label.y - label.height / 2)).toBeLessThan(0.5)

await expect(voice).toHaveCSS("width", "20px")
await expect(voice).toHaveCSS("height", "20px")
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px")
await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("height", "14px")

const faint = await indicator.evaluate((element) => {
const probe = document.createElement("span")
probe.style.color = "var(--v2-icon-icon-faint)"
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()

return color
})

await expect(indicator).toHaveCSS("color", faint)
await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending/)
await timeline.getByLabel("Grouped prompts", { exact: true }).check()
await expect(timeline.locator('[data-run-folded] [data-slot="user-message-copy-wrapper"]')).toBeHidden()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ story.describe("touch timeline", () => {
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)

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

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

for (const action of [
{ slot: "user-message-copy-wrapper", name: "Copy message" },
{ slot: "user-message-copy-wrapper", name: "More actions" },
{ slot: "text-part-copy-wrapper", name: "Copy response" },
]) {
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
Expand All @@ -72,25 +71,29 @@ story("desktop message actions still appear on hover and keyboard focus", async
await actions.locator("..").hover()
await expect(actions).toHaveCSS("opacity", "1")
await expect(actions).toHaveCSS("pointer-events", "auto")

if (action.slot === "text-part-copy-wrapper") {
await expect(actions).toHaveCSS("height", "20px")
await expect(actions).toHaveCSS("margin-top", "4px")
await expect(actions).toHaveCSS("gap", "8px")
await expect(actions).toHaveCSS("margin-top", "8px")
await expect(actions).toHaveCSS("gap", "4px")
const meta = actions.locator('[data-slot="text-part-meta"]')
await expect(meta).toHaveCSS("font-size", "12px")
await expect(meta).toHaveCSS("line-height", "16px")
await expect(meta).toHaveCSS("line-height", "18px")
await expect(meta).toHaveCSS("gap", "4px")
await expect(meta.locator('[data-slot="text-part-meta-item"]')).toHaveText(["Build", "Claude Sonnet 4", "4s"])
const separators = meta.locator('[data-slot="text-part-meta-separator"]')
await expect(separators).toHaveCount(2)

for (const separator of await separators.all()) {
await expect(separator).toHaveText("\u00B7")
await expect(separator).toHaveCSS("opacity", "0.66")
}

const copy = actions.getByRole("button", { name: action.name, exact: true })
await expect(copy).toHaveCSS("width", "20px")
await expect(copy).toHaveCSS("height", "20px")
await expect(copy.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px")
}

await page.mouse.move(0, 0)
await expect(actions).toHaveCSS("opacity", "0")
await actions.getByRole("button", { name: action.name, exact: true }).focus()
Expand Down
37 changes: 16 additions & 21 deletions packages/session-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -168,16 +168,6 @@
color: var(--v2-icon-icon-muted);
}

[data-slot="user-message-pending"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 14px;
height: 16px;
color: var(--v2-icon-icon-faint);
}

[data-slot="user-message-text"] {
display: inline-block;
white-space: pre-wrap;
Expand Down Expand Up @@ -280,7 +270,7 @@

[data-slot="user-message-copy-wrapper"] {
height: calc(20px * var(--user-run-meta, 1));
margin-top: calc(4px * var(--user-run-meta, 1));
margin-top: calc(8px * var(--user-run-meta, 1));
display: flex;
align-items: center;
justify-content: flex-end;
Expand Down Expand Up @@ -326,24 +316,22 @@
overflow: hidden;
text-overflow: ellipsis;

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

[data-slot="user-message-meta-separator"] {
flex: none;
opacity: 0.66;
}

[data-slot="user-message-meta-wrap"],
[data-slot="user-message-meta-tail"] {
font-family: var(--v2-font-family-sans);
font-size: 12px;
font-weight: 440;
line-height: var(--line-height-compact);
line-height: 18px;
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
cursor: default;
Expand Down Expand Up @@ -504,11 +492,11 @@

[data-slot="text-part-copy-wrapper"] {
height: 20px;
margin-top: 4px;
margin-top: 8px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
gap: 4px;

/* Hover actions show and hide at once, without a fade. */
@media (hover: hover) {
Expand All @@ -521,6 +509,16 @@
flex: none;
width: fit-content;
}

[data-component="icon-button-v2"] [data-slot="icon-svg"] {
width: 14px;
height: 14px;
}

/* Pulls the glyph toward the reply's text edge. */
[data-component="tooltip-v2-trigger"]:first-child {
margin-inline-start: -2px;
}
}

[data-slot="text-part-meta"] {
Expand All @@ -533,7 +531,7 @@
font-family: var(--v2-font-family-sans);
font-size: 12px;
font-weight: 440;
line-height: var(--line-height-compact);
line-height: 18px;
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
cursor: default;
Expand All @@ -547,19 +545,16 @@

&[data-variant] {
flex: none;
opacity: 0.66;
}
}

[data-slot="text-part-meta-separator"] {
flex: none;
opacity: 0.66;
}

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

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