Skip to content
Closed
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
205 changes: 205 additions & 0 deletions packages/app/e2e/message-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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 messageText = page.locator('[data-slot="user-message-text"]').first()
await messageText.hover()

await expect(page.locator('[data-slot="message-actions-menu"]')).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 messageText = page.locator('[data-slot="user-message-text"]').first()
await messageText.hover()

const trigger = page.locator('[data-slot="message-actions-menu"]')
await trigger.click()

const copyAction = page.getByRole("menuitem", { name: "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 ?? [])
const first = messages[0]
if (first) messageID = first.info.id
return messages.length
})
.toBeGreaterThan(0)

await gotoSession(sessionID)

const messageText = page.locator('[data-slot="user-message-text"]').first()
await messageText.hover()

const trigger = page.locator('[data-slot="message-actions-menu"]')
await trigger.click()

const revertAction = page.getByRole("menuitem", { name: "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 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-menu"]')
await trigger.click()

const originalUrl = page.url()

const forkAction = page.getByRole("menuitem", { name: "Fork" })
await forkAction.click()

await expect.poll(() => page.url()).not.toBe(originalUrl)

const newUrl = page.url()
const forkedSessionID = newUrl.match(/session\/([^/]+)/)?.[1]

expect(forkedSessionID).toBeTruthy()
expect(forkedSessionID).not.toBe(sessionID)

// 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)
}
})
})
135 changes: 135 additions & 0 deletions packages/app/src/components/message-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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
}

function useMessageActions(props: MessageActionsProps) {
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 = () => {
const msgParts = parts()
if (!msgParts.length) return

const restored = extractPromptFromParts(msgParts, { directory: sdk.directory })

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)
})
}

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 }
}

/** Dropdown menu trigger for message actions (Revert, Fork, Copy) */
export function MessageActionsMenu(props: MessageActionsProps) {
const { menuItems } = useMessageActions(props)

return (
<div data-slot="message-actions-menu">
<DropdownMenu>
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="secondary" />
<DropdownMenu.Portal>
<DropdownMenu.Content>
{menuItems().map((item) => (
<DropdownMenu.Item onSelect={item.onClick} data-slot={item.dataSlot}>
<DropdownMenu.ItemLabel>{item.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemDescription>{item.description}</DropdownMenu.ItemDescription>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
}

/** Context menu wrapper for message actions (right-click menu) */
export function MessageActions(props: ParentProps<MessageActionsProps>) {
const { menuItems } = useMessageActions(props)

return (
<ContextMenu>
<ContextMenu.Trigger as="div" data-component="message-actions">
{props.children}
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content data-component="context-menu-content">
{menuItems().map((item) => (
<ContextMenu.Item onSelect={item.onClick} data-slot={item.dataSlot}>
<ContextMenu.ItemLabel>{item.label}</ContextMenu.ItemLabel>
<ContextMenu.ItemDescription>{item.description}</ContextMenu.ItemDescription>
</ContextMenu.Item>
))}
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
Loading