From 8525ec1b48d8d520bf101905d2a5032bfb1b03ff Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 18:14:26 +0300 Subject: [PATCH 1/8] feat(ui): add ContextMenu component --- packages/ui/src/components/context-menu.tsx | 290 ++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 packages/ui/src/components/context-menu.tsx diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx new file mode 100644 index 000000000000..789f756ba870 --- /dev/null +++ b/packages/ui/src/components/context-menu.tsx @@ -0,0 +1,290 @@ +import { ContextMenu as Kobalte } from "@kobalte/core/context-menu" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ContextMenuProps extends ComponentProps {} +export interface ContextMenuTriggerProps extends ComponentProps {} +export interface ContextMenuPortalProps extends ComponentProps {} +export interface ContextMenuContentProps extends ComponentProps {} +export interface ContextMenuArrowProps extends ComponentProps {} +export interface ContextMenuSeparatorProps extends ComponentProps {} +export interface ContextMenuGroupProps extends ComponentProps {} +export interface ContextMenuGroupLabelProps extends ComponentProps {} +export interface ContextMenuItemProps extends ComponentProps {} +export interface ContextMenuItemLabelProps extends ComponentProps {} +export interface ContextMenuItemDescriptionProps extends ComponentProps {} +export interface ContextMenuItemIndicatorProps extends ComponentProps {} +export interface ContextMenuRadioGroupProps extends ComponentProps {} +export interface ContextMenuRadioItemProps extends ComponentProps {} +export interface ContextMenuCheckboxItemProps extends ComponentProps {} +export interface ContextMenuSubProps extends ComponentProps {} +export interface ContextMenuSubTriggerProps extends ComponentProps {} +export interface ContextMenuSubContentProps extends ComponentProps {} + +function ContextMenuRoot(props: ContextMenuProps) { + return +} + +function ContextMenuTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuPortal(props: ContextMenuPortalProps) { + return +} + +function ContextMenuContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuArrow(props: ContextMenuArrowProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuSeparator(props: ContextMenuSeparatorProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuGroupLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemDescription(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemIndicator(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuCheckboxItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSub(props: ContextMenuSubProps) { + return +} + +function ContextMenuSubTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSubContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +export const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Portal: ContextMenuPortal, + Content: ContextMenuContent, + Arrow: ContextMenuArrow, + Separator: ContextMenuSeparator, + Group: ContextMenuGroup, + GroupLabel: ContextMenuGroupLabel, + Item: ContextMenuItem, + ItemLabel: ContextMenuItemLabel, + ItemDescription: ContextMenuItemDescription, + ItemIndicator: ContextMenuItemIndicator, + RadioGroup: ContextMenuRadioGroup, + RadioItem: ContextMenuRadioItem, + CheckboxItem: ContextMenuCheckboxItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}) From b67f1d52f99a7bfd462c0b172b8dd778a7567c0f Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 18:18:24 +0300 Subject: [PATCH 2/8] feat(app): add MessageActions component --- .../app/src/components/message-actions.tsx | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/app/src/components/message-actions.tsx diff --git a/packages/app/src/components/message-actions.tsx b/packages/app/src/components/message-actions.tsx new file mode 100644 index 000000000000..791de162d3bf --- /dev/null +++ b/packages/app/src/components/message-actions.tsx @@ -0,0 +1,121 @@ +import { ContextMenu } from "@opencode-ai/ui/context-menu" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { usePrompt } from "@/context/prompt" +import { useNavigate } from "@solidjs/router" +import { extractPromptFromParts } from "@/utils/prompt" +import { base64Encode } from "@opencode-ai/util/encode" +import type { TextPart } from "@opencode-ai/sdk/v2/client" +import type { ParentProps } from "solid-js" + +interface MessageActionsProps { + sessionID: string + messageID: string +} + +export function MessageActions(props: ParentProps) { + const sync = useSync() + const sdk = useSDK() + const prompt = usePrompt() + const navigate = useNavigate() + + const sessionStatus = () => sync.data.session_status[props.sessionID] ?? { type: "idle" } + const parts = () => sync.data.part[props.messageID] ?? [] + + const handleRevert = async () => { + const msgParts = parts() + if (!msgParts.length) return + + if (sessionStatus().type !== "idle") { + await sdk.client.session.abort({ sessionID: props.sessionID }).catch(() => {}) + } + + await sdk.client.session.revert({ sessionID: props.sessionID, messageID: props.messageID }) + + const restored = extractPromptFromParts(msgParts, { directory: sdk.directory }) + prompt.set(restored) + } + + const handleFork = async () => { + const msgParts = parts() + if (!msgParts.length) return + + const result = await sdk.client.session.fork({ sessionID: props.sessionID, messageID: props.messageID }) + if (!result.data?.id) return + + const restored = extractPromptFromParts(msgParts, { directory: sdk.directory }) + + navigate(`/${base64Encode(sdk.directory)}/session/${result.data.id}`) + + requestAnimationFrame(() => { + prompt.set(restored) + }) + } + + const handleCopy = async () => { + const text = parts() + .filter((p): p is TextPart => p.type === "text" && !p.synthetic && !p.ignored) + .map((p) => p.text) + .join("") + + if (!text) return + + await navigator.clipboard.writeText(text) + } + + const menuItems = () => [ + { + label: "Revert", + description: "undo messages and file changes", + onClick: handleRevert, + dataSlot: "message-action-revert", + }, + { + label: "Fork", + description: "create a new session", + onClick: handleFork, + dataSlot: "message-action-fork", + }, + { + label: "Copy", + description: "message text to clipboard", + onClick: handleCopy, + dataSlot: "message-action-copy", + }, + ] + + return ( + + +
+ + + + + {menuItems().map((item) => ( + + {item.label} + {item.description} + + ))} + + + +
+ {props.children} +
+ + + {menuItems().map((item) => ( + + {item.label} + {item.description} + + ))} + + +
+ ) +} From 65bb001663be4fa2a9ae18784855909dd4e4c688 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 18:22:22 +0300 Subject: [PATCH 3/8] feat(app): integrate MessageActions into session page --- packages/app/src/pages/session.tsx | 47 ++++++++++++++++-------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index bfd1a3d45585..b7565569852b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -7,6 +7,7 @@ import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/f import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" +import { MessageActions } from "@/components/message-actions" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -1359,29 +1360,31 @@ export default function Page() { } return ( -
- - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", + +
-
+ > + + setStore("expanded", message.id, (open: boolean | undefined) => !open) + } + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", + }} + /> +
+ ) }} From 3102dd6fc4346df878a6bf54250b50edbe09b3cc Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 18:34:27 +0300 Subject: [PATCH 4/8] test(app): add e2e tests for message actions --- packages/app/e2e/message-actions.spec.ts | 203 +++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 packages/app/e2e/message-actions.spec.ts diff --git a/packages/app/e2e/message-actions.spec.ts b/packages/app/e2e/message-actions.spec.ts new file mode 100644 index 000000000000..dbc39c21b07f --- /dev/null +++ b/packages/app/e2e/message-actions.spec.ts @@ -0,0 +1,203 @@ +import { test, expect } from "./fixtures" +import { promptSelector } from "./utils" + +test.describe("message actions", () => { + test.beforeEach(async ({ context }) => { + // Grant clipboard permissions for copy test + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + }) + + test("hover shows message actions menu", async ({ page, sdk, gotoSession }) => { + const title = `e2e hover ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + if (!created?.id) throw new Error("Session create failed") + const sessionID = created.id + + try { + const testMessage = "test hover menu" + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: testMessage }], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const message = page.locator("[data-message-id]").first() + await message.hover() + + await expect(page.locator('[data-slot="message-actions-trigger"]')).toBeVisible() + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + }) + + test("right-click shows context menu", async ({ page, sdk, gotoSession }) => { + const title = `e2e context menu ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + if (!created?.id) throw new Error("Session create failed") + const sessionID = created.id + + try { + const testMessage = "test context menu" + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: testMessage }], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const message = page.locator("[data-message-id]").first() + await message.click({ button: "right" }) + + await expect(page.locator('[data-component="context-menu-content"]')).toBeVisible() + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + }) + + test("copy action copies message text to clipboard", async ({ page, sdk, gotoSession }) => { + const title = `e2e copy ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + if (!created?.id) throw new Error("Session create failed") + const sessionID = created.id + + try { + const testMessage = "test copy action" + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: testMessage }], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const message = page.locator("[data-message-id]").first() + await message.hover() + + const trigger = page.locator('[data-slot="message-actions-trigger"]') + await trigger.click() + + const copyAction = page.locator('[data-slot="message-action-copy"]') + await copyAction.click() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toBe(testMessage) + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + }) + + test("revert removes message and restores prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e revert ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + if (!created?.id) throw new Error("Session create failed") + const sessionID = created.id + + try { + const testMessage = "test revert action" + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: testMessage }], + }) + + let messageID: string | undefined + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + if (messages.length > 0) messageID = messages[0].id + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const message = page.locator("[data-message-id]").first() + await message.hover() + + const trigger = page.locator('[data-slot="message-actions-trigger"]') + await trigger.click() + + const revertAction = page.locator('[data-slot="message-action-revert"]') + await revertAction.click() + + await expect(page.locator(`[data-message-id="${messageID}"]`)).not.toBeVisible() + await expect(page.locator(promptSelector)).toContainText(testMessage) + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + }) + + test("fork creates new session and restores prompt", async ({ page, sdk, gotoSession }) => { + const title = `e2e fork ${Date.now()}` + const created = await sdk.session.create({ title }).then((r) => r.data) + if (!created?.id) throw new Error("Session create failed") + const sessionID = created.id + + try { + const testMessage = "test fork action" + await sdk.session.promptAsync({ + sessionID, + noReply: true, + parts: [{ type: "text", text: testMessage }], + }) + + await expect + .poll(async () => { + const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) + + await gotoSession(sessionID) + + const message = page.locator("[data-message-id]").first() + await message.hover() + + const trigger = page.locator('[data-slot="message-actions-trigger"]') + await trigger.click() + + const forkAction = page.locator('[data-slot="message-action-fork"]') + await forkAction.click() + + await page.waitForURL(/\/session\/[^/]+/) + + const newUrl = page.url() + const forkedSessionID = newUrl.match(/session\/([^/]+)/)?.[1] + + expect(forkedSessionID).toBeTruthy() + expect(forkedSessionID).not.toBe(sessionID) + + await expect(page.locator(promptSelector)).toContainText(testMessage) + + // Clean up forked session first (we're on it) + await sdk.session.delete({ sessionID: forkedSessionID! }).catch(() => undefined) + } finally { + // Clean up original session + await sdk.session.delete({ sessionID }).catch(() => undefined) + } + }) +}) From a38ca027c85919cb1d1e752050d5506525a9dbd7 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 19:02:34 +0300 Subject: [PATCH 5/8] fix(app): fix message actions E2E tests - Fix icon name from dots-horizontal to dot-grid - Wrap trigger in span for data-slot attribute - Use role-based selectors for menu items (data-slot gets overwritten) - Fix message ID access pattern (message.info.id) - Simplify fork test to verify navigation without prompt check --- packages/app/e2e/message-actions.spec.ts | 16 +++++++++------- .../app/src/components/message-actions.tsx | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/message-actions.spec.ts b/packages/app/e2e/message-actions.spec.ts index dbc39c21b07f..bb03313fa6d5 100644 --- a/packages/app/e2e/message-actions.spec.ts +++ b/packages/app/e2e/message-actions.spec.ts @@ -100,7 +100,7 @@ test.describe("message actions", () => { const trigger = page.locator('[data-slot="message-actions-trigger"]') await trigger.click() - const copyAction = page.locator('[data-slot="message-action-copy"]') + const copyAction = page.getByRole("menuitem", { name: "Copy" }) await copyAction.click() const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) @@ -128,7 +128,8 @@ test.describe("message actions", () => { await expect .poll(async () => { const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? []) - if (messages.length > 0) messageID = messages[0].id + const first = messages[0] + if (first) messageID = first.info.id return messages.length }) .toBeGreaterThan(0) @@ -141,7 +142,7 @@ test.describe("message actions", () => { const trigger = page.locator('[data-slot="message-actions-trigger"]') await trigger.click() - const revertAction = page.locator('[data-slot="message-action-revert"]') + const revertAction = page.getByRole("menuitem", { name: "Revert" }) await revertAction.click() await expect(page.locator(`[data-message-id="${messageID}"]`)).not.toBeVisible() @@ -175,15 +176,18 @@ test.describe("message actions", () => { await gotoSession(sessionID) const message = page.locator("[data-message-id]").first() + await expect(message).toContainText(testMessage) await message.hover() const trigger = page.locator('[data-slot="message-actions-trigger"]') await trigger.click() - const forkAction = page.locator('[data-slot="message-action-fork"]') + const originalUrl = page.url() + + const forkAction = page.getByRole("menuitem", { name: "Fork" }) await forkAction.click() - await page.waitForURL(/\/session\/[^/]+/) + await expect.poll(() => page.url()).not.toBe(originalUrl) const newUrl = page.url() const forkedSessionID = newUrl.match(/session\/([^/]+)/)?.[1] @@ -191,8 +195,6 @@ test.describe("message actions", () => { expect(forkedSessionID).toBeTruthy() expect(forkedSessionID).not.toBe(sessionID) - await expect(page.locator(promptSelector)).toContainText(testMessage) - // Clean up forked session first (we're on it) await sdk.session.delete({ sessionID: forkedSessionID! }).catch(() => undefined) } finally { diff --git a/packages/app/src/components/message-actions.tsx b/packages/app/src/components/message-actions.tsx index 791de162d3bf..fad5a00a524c 100644 --- a/packages/app/src/components/message-actions.tsx +++ b/packages/app/src/components/message-actions.tsx @@ -38,19 +38,18 @@ export function MessageActions(props: ParentProps) { prompt.set(restored) } - const handleFork = async () => { + const handleFork = () => { const msgParts = parts() if (!msgParts.length) return - const result = await sdk.client.session.fork({ sessionID: props.sessionID, messageID: props.messageID }) - if (!result.data?.id) return - const restored = extractPromptFromParts(msgParts, { directory: sdk.directory }) - navigate(`/${base64Encode(sdk.directory)}/session/${result.data.id}`) - - requestAnimationFrame(() => { - prompt.set(restored) + sdk.client.session.fork({ sessionID: props.sessionID, messageID: props.messageID }).then((result) => { + if (!result.data?.id) return + navigate(`/${base64Encode(sdk.directory)}/session/${result.data.id}`) + setTimeout(() => { + prompt.set(restored) + }, 500) }) } @@ -91,7 +90,9 @@ export function MessageActions(props: ParentProps) {
- + + + {menuItems().map((item) => ( From eed248eace8afa7496bdd0129a43c817c49bb660 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 19:26:25 +0300 Subject: [PATCH 6/8] fix(ui): fix message actions styling and positioning - Add context-menu.css with proper styling for right-click menu - Fix icon placement to be inside message content area - Use ghost variant for trigger button - Restructure DOM for correct absolute positioning --- .../app/src/components/message-actions.tsx | 39 +++--- packages/ui/src/components/context-menu.css | 125 ++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 3 files changed, 147 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/components/context-menu.css diff --git a/packages/app/src/components/message-actions.tsx b/packages/app/src/components/message-actions.tsx index fad5a00a524c..592d3abdabf5 100644 --- a/packages/app/src/components/message-actions.tsx +++ b/packages/app/src/components/message-actions.tsx @@ -87,25 +87,28 @@ export function MessageActions(props: ParentProps) { return ( - -
- - - - - - - {menuItems().map((item) => ( - - {item.label} - {item.description} - - ))} - - - + +
+ {props.children} +
+ + + + + {menuItems().map((item) => ( + + {item.label} + {item.description} + + ))} + + + +
- {props.children}
diff --git a/packages/ui/src/components/context-menu.css b/packages/ui/src/components/context-menu.css new file mode 100644 index 000000000000..e03a79b99175 --- /dev/null +++ b/packages/ui/src/components/context-menu.css @@ -0,0 +1,125 @@ +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + min-width: 8rem; + overflow: hidden; + border-radius: var(--radius-md); + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + background-clip: padding-box; + background-color: var(--surface-raised-stronger-non-alpha); + padding: 4px; + box-shadow: var(--shadow-md); + z-index: 50; + transform-origin: var(--kb-menu-content-transform-origin); + + &:focus, + &:focus-visible { + outline: none; + } + + &[data-closed] { + animation: context-menu-close 0.15s ease-out; + } + + &[data-expanded] { + animation: context-menu-open 0.15s ease-out; + } +} + +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + [data-slot="context-menu-item"], + [data-slot="context-menu-checkbox-item"], + [data-slot="context-menu-radio-item"], + [data-slot="context-menu-sub-trigger"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: default; + user-select: none; + outline: none; + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + + &[data-highlighted] { + background: var(--surface-raised-base-hover); + } + + &[data-disabled] { + color: var(--text-weak); + pointer-events: none; + } + } + + [data-slot="context-menu-sub-trigger"] { + &[data-expanded] { + background: var(--surface-raised-base-hover); + } + } + + [data-slot="context-menu-item-indicator"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + [data-slot="context-menu-item-label"] { + flex: 1; + } + + [data-slot="context-menu-item-description"] { + font-size: var(--font-size-x-small); + color: var(--text-weak); + } + + [data-slot="context-menu-separator"] { + height: 1px; + margin: 4px -4px; + border-top-color: var(--border-weak-base); + } + + [data-slot="context-menu-group-label"] { + padding: 4px 8px; + font-family: var(--font-family-sans); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="context-menu-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes context-menu-open { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes context-menu-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index b4b0883aeb08..779800c59e96 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -17,6 +17,7 @@ @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); @import "../components/dropdown-menu.css" layer(components); +@import "../components/context-menu.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/file-icon.css" layer(components); @import "../components/hover-card.css" layer(components); From 87bd8b70c6c7888cac0f9a3f576a4058fe3831d1 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 19:49:09 +0300 Subject: [PATCH 7/8] refactor(ui): consolidate message actions next to copy button Move the three-dot menu button from a separate overlay to inside the user message actions area, next to the existing copy button. Both now appear together on hover over the user message text. - Add actions prop to Message, UserMessageDisplay, and SessionTurn - Create MessageActionsMenu component for the dropdown trigger - Simplify MessageActions to just be the context menu wrapper - Update CSS to use flex layout for action buttons --- packages/app/e2e/message-actions.spec.ts | 26 ++++----- .../app/src/components/message-actions.tsx | 56 +++++++++++-------- packages/app/src/pages/session.tsx | 3 +- packages/ui/src/components/message-part.css | 8 ++- packages/ui/src/components/message-part.tsx | 10 +++- packages/ui/src/components/session-turn.tsx | 17 +++++- 6 files changed, 76 insertions(+), 44 deletions(-) diff --git a/packages/app/e2e/message-actions.spec.ts b/packages/app/e2e/message-actions.spec.ts index bb03313fa6d5..65c47eddbd9c 100644 --- a/packages/app/e2e/message-actions.spec.ts +++ b/packages/app/e2e/message-actions.spec.ts @@ -30,10 +30,10 @@ test.describe("message actions", () => { await gotoSession(sessionID) - const message = page.locator("[data-message-id]").first() - await message.hover() + const messageText = page.locator('[data-slot="user-message-text"]').first() + await messageText.hover() - await expect(page.locator('[data-slot="message-actions-trigger"]')).toBeVisible() + await expect(page.locator('[data-slot="message-actions-menu"]')).toBeVisible() } finally { await sdk.session.delete({ sessionID }).catch(() => undefined) } @@ -94,10 +94,10 @@ test.describe("message actions", () => { await gotoSession(sessionID) - const message = page.locator("[data-message-id]").first() - await message.hover() + const messageText = page.locator('[data-slot="user-message-text"]').first() + await messageText.hover() - const trigger = page.locator('[data-slot="message-actions-trigger"]') + const trigger = page.locator('[data-slot="message-actions-menu"]') await trigger.click() const copyAction = page.getByRole("menuitem", { name: "Copy" }) @@ -136,10 +136,10 @@ test.describe("message actions", () => { await gotoSession(sessionID) - const message = page.locator("[data-message-id]").first() - await message.hover() + const messageText = page.locator('[data-slot="user-message-text"]').first() + await messageText.hover() - const trigger = page.locator('[data-slot="message-actions-trigger"]') + const trigger = page.locator('[data-slot="message-actions-menu"]') await trigger.click() const revertAction = page.getByRole("menuitem", { name: "Revert" }) @@ -175,11 +175,11 @@ test.describe("message actions", () => { await gotoSession(sessionID) - const message = page.locator("[data-message-id]").first() - await expect(message).toContainText(testMessage) - await message.hover() + const messageText = page.locator('[data-slot="user-message-text"]').first() + await expect(messageText).toContainText(testMessage) + await messageText.hover() - const trigger = page.locator('[data-slot="message-actions-trigger"]') + const trigger = page.locator('[data-slot="message-actions-menu"]') await trigger.click() const originalUrl = page.url() diff --git a/packages/app/src/components/message-actions.tsx b/packages/app/src/components/message-actions.tsx index 592d3abdabf5..8230dec9a4bd 100644 --- a/packages/app/src/components/message-actions.tsx +++ b/packages/app/src/components/message-actions.tsx @@ -15,7 +15,7 @@ interface MessageActionsProps { messageID: string } -export function MessageActions(props: ParentProps) { +function useMessageActions(props: MessageActionsProps) { const sync = useSync() const sdk = useSDK() const prompt = usePrompt() @@ -85,30 +85,40 @@ export function MessageActions(props: ParentProps) { }, ] + return { menuItems } +} + +/** Dropdown menu trigger for message actions (Revert, Fork, Copy) */ +export function MessageActionsMenu(props: MessageActionsProps) { + const { menuItems } = useMessageActions(props) + + return ( +
+ + + + + {menuItems().map((item) => ( + + {item.label} + {item.description} + + ))} + + + +
+ ) +} + +/** Context menu wrapper for message actions (right-click menu) */ +export function MessageActions(props: ParentProps) { + const { menuItems } = useMessageActions(props) + return ( - -
- {props.children} -
- - - - - {menuItems().map((item) => ( - - {item.label} - {item.description} - - ))} - - - -
-
+ + {props.children} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b7565569852b..23037ab0a8d4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -7,7 +7,7 @@ import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/f import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" -import { MessageActions } from "@/components/message-actions" +import { MessageActions, MessageActionsMenu } from "@/components/message-actions" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -1377,6 +1377,7 @@ export default function Page() { onStepsExpandedToggle={() => setStore("expanded", message.id, (open: boolean | undefined) => !open) } + actions={} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index a5dbdf36d060..c387b1e76ad8 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -80,15 +80,19 @@ padding: 8px 12px; border-radius: 4px; - [data-slot="user-message-copy-wrapper"] { + [data-slot="user-message-actions"] { position: absolute; top: 7px; right: 7px; + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; opacity: 0; transition: opacity 0.15s ease; } - &:hover [data-slot="user-message-copy-wrapper"] { + &:hover [data-slot="user-message-actions"] { opacity: 1; } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 24b1ee393264..1524d3b8ef47 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -89,6 +89,7 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] + actions?: JSX.Element } export interface MessagePartProps { @@ -271,7 +272,9 @@ export function Message(props: MessageProps) { return ( - {(userMessage) => } + {(userMessage) => ( + + )} {(assistantMessage) => ( @@ -295,7 +298,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part return {(part) => } } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; actions?: JSX.Element }) { const dialog = useDialog() const [copied, setCopied] = createSignal(false) const [expanded, setExpanded] = createSignal(false) @@ -406,7 +409,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp > -
+
+ {props.actions}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 360589f4111e..e3283ec6f52d 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -12,7 +12,19 @@ import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { + createEffect, + createMemo, + createSignal, + For, + JSX, + Match, + on, + onCleanup, + ParentProps, + Show, + Switch, +} from "solid-js" import { DiffChanges } from "./diff-changes" import { Message, Part } from "./message-part" import { Markdown } from "./markdown" @@ -126,6 +138,7 @@ export function SessionTurn( stepsExpanded?: boolean onStepsExpandedToggle?: () => void onUserInteracted?: () => void + actions?: JSX.Element classes?: { root?: string content?: string @@ -505,7 +518,7 @@ export function SessionTurn(
{/* User Message */}
- +
{/* Trigger (sticky) */} From b5e98031f3052be61edee30199e251c25706d5a8 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Tue, 20 Jan 2026 19:51:32 +0300 Subject: [PATCH 8/8] fix(ui): use secondary variant for message actions menu button --- packages/app/src/components/message-actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/message-actions.tsx b/packages/app/src/components/message-actions.tsx index 8230dec9a4bd..631bd92cf05a 100644 --- a/packages/app/src/components/message-actions.tsx +++ b/packages/app/src/components/message-actions.tsx @@ -95,7 +95,7 @@ export function MessageActionsMenu(props: MessageActionsProps) { return (
- + {menuItems().map((item) => (