diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts new file mode 100644 index 000000000..bfbb6bb6b --- /dev/null +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -0,0 +1,278 @@ +import { expect, test } from "./fixtures"; +import { clickNewChat } from "./helpers/launcher"; +import { expectComposerVisible } from "./helpers/composer"; +import { expectAgentIdle } from "./helpers/agent-stream"; +import { + openAttachmentMenu, + openGithubPickerFromMenu, + attachImageFromMenu, + expectAttachmentPill, + removeAttachmentPill, + openImageLightbox, + closeImageLightbox, + pressInterruptShortcut, + expectComposerDraft, + expectComposerDisabled, + expectComposerEditable, + expectAttachButtonDisabled, + fillComposerDraft, + sendDraftToQueue, + expectQueuedMessageButton, + startRunningMockAgent, + selectGithubOption, + expectGithubAttachmentPill, + openGithubWorkspace, +} from "./helpers/composer"; +import { + clickNewWorkspaceButton, + connectNewWorkspaceDaemonClient, + delayBrowserAgentCreatedStatus, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { gotoAppShell } from "./helpers/app"; +import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; +import { createTempGitRepo } from "./helpers/workspace"; +import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures"; + +const MINIMAL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG }; + +test.describe("Composer attachments", () => { + test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-plus-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await openAttachmentMenu(page); + + await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible(); + await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible(); + }); + + test("GitHub combobox does not render until the picker is opened", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible(); + + await openGithubPickerFromMenu(page); + + await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")), + ).toBeVisible({ timeout: 10_000 }); + }); + + test("GitHub issue attachment pill visible after search and selection", async ({ page }) => { + test.setTimeout(120_000); + if (!hasGithubAuth()) { + test.skip(true, "GitHub auth not available in this environment"); + } + + const ghRepo = await createTempGithubRepo({ + prefix: "paseo-e2e-attach-issue-", + issues: [{ title: "fix: attach-issue-unique-alpha" }], + prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }], + }); + const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); + try { + await clickNewChat(page); + await expectComposerVisible(page); + + await selectGithubOption( + page, + "attach-issue-unique-alpha", + `issue:${ghRepo.issues[0].number}`, + ); + + await expectGithubAttachmentPill(page, { + number: ghRepo.issues[0].number, + title: ghRepo.issues[0].title, + }); + } finally { + await handle.cleanup(); + await ghRepo.cleanup(); + } + }); + + test("GitHub PR attachment pill visible after search and selection", async ({ page }) => { + test.setTimeout(120_000); + if (!hasGithubAuth()) { + test.skip(true, "GitHub auth not available in this environment"); + } + + const ghRepo = await createTempGithubRepo({ + prefix: "paseo-e2e-attach-pr-", + prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }], + }); + const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath); + try { + await clickNewChat(page); + await expectComposerVisible(page); + + await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`); + + await expectGithubAttachmentPill(page, { + number: ghRepo.prs[0].number, + title: ghRepo.prs[0].title, + }); + } finally { + await handle.cleanup(); + await ghRepo.cleanup(); + } + }); + + test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => { + // The review attachment is created via InlineReviewEditor in surface.tsx (addComment action). + // Automating this requires: a workspace with staged changes, navigating to the diff panel, + // hovering the gutter "+" button, typing a comment, and submitting. A dedicated + // helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is + // needed before this can be exercised end-to-end. + }); + + test.fixme("browser-element attachment pill is created from Electron webview selection", async () => { + // The browser-element attachment is only created in browser-pane.electron.tsx via DOM + // element selection in the Electron webview. It is not exercisable in headless Chromium E2E. + }); + + test("image lightbox opens on pill click and closes on Escape", async ({ + page, + withWorkspace, + }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-lightbox-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await attachImageFromMenu(page, TEST_IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + + await openImageLightbox(page); + await closeImageLightbox(page); + }); + + test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-pill-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await attachImageFromMenu(page, TEST_IMAGE); + + await expectAttachmentPill(page, "composer-image-attachment-pill"); + }); + + test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => { + test.setTimeout(60_000); + const workspace = await withWorkspace({ prefix: "attach-remove-" }); + await workspace.navigateTo(); + await clickNewChat(page); + await expectComposerVisible(page); + + await attachImageFromMenu(page, TEST_IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + + await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment"); + + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, { + timeout: 5_000, + }); + }); + + test("submitting while agent is running queues the message and clears the draft", async ({ + page, + }) => { + test.setTimeout(120_000); + const { client, repo } = await startRunningMockAgent(page, { + prefix: "attach-queue-", + model: "one-minute-stream", + prompt: "Stay running for queue test.", + }); + try { + await fillComposerDraft(page, "queued draft text"); + await sendDraftToQueue(page); + + await expectQueuedMessageButton(page); + await expectComposerDraft(page, ""); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("Escape interrupt cancels the running agent and preserves composer draft", async ({ + page, + }) => { + test.setTimeout(120_000); + const { client, repo } = await startRunningMockAgent(page, { + prefix: "attach-interrupt-", + model: "ten-second-stream", + prompt: "Stay running for interrupt test.", + }); + try { + await fillComposerDraft(page, "preserve me"); + await pressInterruptShortcut(page); + + await expectAgentIdle(page, 15_000); + await expectComposerDraft(page, "preserve me"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("composer is locked while new workspace agent is being created", async ({ page }) => { + test.setTimeout(120_000); + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) throw new Error("E2E_SERVER_ID is not set."); + + const repo = await createTempGitRepo("attach-lock-"); + const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); + const daemonClient = await connectNewWorkspaceDaemonClient(); + + try { + const openedProject = await openProjectViaDaemon(daemonClient, repo.path); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: openedProject.workspaceId, + }); + + await clickNewWorkspaceButton(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + + await agentCreatedDelay.waitForCreateRequest(); + await agentCreatedDelay.waitForDelayedCreatedStatus(); + + await expectComposerDisabled(page); + await expectAttachButtonDisabled(page); + + agentCreatedDelay.release(); + + await expectComposerEditable(page); + } finally { + agentCreatedDelay.release(); + await daemonClient.close().catch(() => undefined); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index b39dc1b65..3001da742 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -1,4 +1,10 @@ import { expect, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { createTempGitRepo } from "./workspace"; +import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf"; +import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup"; +import { selectWorkspaceInSidebar } from "./sidebar"; +import { waitForTabBar } from "./launcher"; function composerInput(page: Page) { return page.getByRole("textbox", { name: "Message agent..." }).first(); @@ -15,6 +21,18 @@ export async function expectComposerVisible( await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 }); } +export async function expectComposerDisabled(page: Page): Promise { + await expect(composerInput(page)).toBeDisabled({ timeout: 10_000 }); +} + +export async function expectComposerDraft(page: Page, text: string): Promise { + await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 }); +} + +export async function expectComposerEditable(page: Page): Promise { + await expect(composerInput(page)).toBeEditable({ timeout: 15_000 }); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); @@ -22,8 +40,154 @@ export async function submitMessage(page: Page, text: string): Promise { await input.press("Enter"); } +export async function fillComposerDraft(page: Page, text: string): Promise { + await composerInput(page).fill(text); +} + +export async function sendDraftToQueue(page: Page): Promise { + await composerInput(page).press("Control+Enter"); +} + +export async function expectQueuedMessageButton(page: Page): Promise { + await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({ + timeout: 10_000, + }); +} + export async function cancelAgent(page: Page): Promise { const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first(); await expect(stopButton).toBeVisible({ timeout: 10_000 }); await stopButton.click(); } + +/** Escape is bound to the "agent.interrupt" keyboard shortcut. */ +export async function pressInterruptShortcut(page: Page): Promise { + await page.keyboard.press("Escape"); +} + +export async function openAttachmentMenu(page: Page): Promise { + await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click(); + await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 }); +} + +export async function expectAttachButtonDisabled(page: Page): Promise { + await expect( + page.getByTestId("message-input-attach-button").filter({ visible: true }).first(), + ).toBeDisabled({ timeout: 10_000 }); +} + +export async function attachImageFromMenu( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 }); + await openAttachmentMenu(page); + await page.getByTestId("message-input-attachment-menu-item-image").click(); + const chooser = await chooserPromise; + await chooser.setFiles([file]); +} + +export async function expectAttachmentPill(page: Page, testID: string): Promise { + await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 }); +} + +/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */ +export async function removeAttachmentPill( + page: Page, + pillTestId: string, + removeAccessibilityLabel: string, +): Promise { + await page.getByTestId(pillTestId).first().hover(); + await page.getByRole("button", { name: removeAccessibilityLabel }).first().click(); +} + +export async function expectGithubAttachmentPill( + page: Page, + input: { number: number; title: string }, +): Promise { + const pill = page.getByTestId("composer-github-attachment-pill").first(); + await expect(pill).toBeVisible({ timeout: 10_000 }); + await expect(pill).toContainText(`#${input.number}`); + await expect(pill).toContainText(input.title); +} + +export async function openImageLightbox(page: Page): Promise { + await page.getByRole("button", { name: "Open image attachment" }).first().click(); + await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 }); +} + +export async function closeImageLightbox(page: Page): Promise { + await page.keyboard.press("Escape"); + await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 }); +} + +export async function openGithubPickerFromMenu(page: Page): Promise { + await openAttachmentMenu(page); + await page.getByTestId("message-input-attachment-menu-item-github").click(); + await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 }); +} + +/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */ +export async function selectGithubOption( + page: Page, + searchTerm: string, + optionId: string, +): Promise { + await openGithubPickerFromMenu(page); + const searchInput = page.getByPlaceholder("Search issues and PRs..."); + await expect(searchInput).toBeVisible({ timeout: 5_000 }); + await searchInput.fill(searchTerm); + const option = page.getByTestId(`composer-github-option-${optionId}`); + await expect(option).toBeVisible({ timeout: 15_000 }); + await option.click(); +} + +export interface MockAgentSetup { + client: TerminalPerfDaemonClient; + repo: Awaited>; +} + +/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */ +export async function startRunningMockAgent( + page: Page, + opts: { prefix: string; model: string; prompt: string }, +): Promise { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) throw new Error("E2E_SERVER_ID is not set."); + + const repo = await createTempGitRepo(opts.prefix); + const client = await connectTerminalClient(); + const opened = await client.openProject(repo.path); + if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project"); + const agent = await client.createAgent({ + provider: "mock", + cwd: repo.path, + model: opts.model, + initialPrompt: opts.prompt, + }); + const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; + await page.goto(agentUrl); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ + timeout: 30_000, + }); + await expectComposerVisible(page); + return { client, repo }; +} + +export interface GithubWorkspaceHandle { + cleanup: () => Promise; +} + +/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */ +export async function openGithubWorkspace( + page: Page, + repoPath: string, +): Promise { + const client = await connectWorkspaceSetupClient(); + const opened = await client.openProject(repoPath); + if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`); + await openHomeWithProject(page, repoPath); + await selectWorkspaceInSidebar(page, opened.workspace.id); + await waitForTabBar(page); + return { cleanup: () => client.close().catch(() => undefined) }; +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 4bd698b57..290aa5343 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -250,3 +250,110 @@ export async function assertNewWorkspaceSidebarAndHeader( return { workspaceId }; } + +type WebSocketMessage = string | Buffer; + +function parseWebSocketJson(message: WebSocketMessage): unknown { + const rawMessage = typeof message === "string" ? message : message.toString("utf8"); + try { + return JSON.parse(rawMessage); + } catch { + return null; + } +} + +function getSessionMessage(message: WebSocketMessage): Record | null { + const envelope = parseWebSocketJson(message); + if (!envelope || typeof envelope !== "object") { + return null; + } + const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; + if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { + return null; + } + if (typeof maybeEnvelope.message !== "object") { + return null; + } + return maybeEnvelope.message as Record; +} + +function getStringField(input: Record, key: string): string | null { + const value = input[key]; + return typeof value === "string" ? value : null; +} + +export interface AgentCreatedDelayControl { + release(): void; + waitForCreateRequest(): Promise; + waitForDelayedCreatedStatus(): Promise; +} + +export async function delayBrowserAgentCreatedStatus( + page: Page, +): Promise { + const daemonPort = process.env.E2E_DAEMON_PORT; + if (!daemonPort) { + throw new Error("E2E_DAEMON_PORT is not set."); + } + + const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); + const createRequestIds = new Set(); + const delayedForwards: Array<() => void> = []; + let releaseRequested = false; + let resolveCreateRequest: (() => void) | null = null; + let resolveDelayedCreatedStatus: (() => void) | null = null; + const createRequestSeen = new Promise((resolve) => { + resolveCreateRequest = resolve; + }); + const delayedCreatedStatusSeen = new Promise((resolve) => { + resolveDelayedCreatedStatus = resolve; + }); + + await page.routeWebSocket(daemonPortPattern, (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if (sessionMessage?.type === "create_agent_request") { + const requestId = getStringField(sessionMessage, "requestId"); + if (requestId) { + createRequestIds.add(requestId); + resolveCreateRequest?.(); + } + } + server.send(message); + }); + + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = + sessionMessage?.type === "status" && typeof sessionMessage.payload === "object" + ? (sessionMessage.payload as Record) + : null; + const requestId = payload ? getStringField(payload, "requestId") : null; + + if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) { + resolveDelayedCreatedStatus?.(); + if (releaseRequested) { + ws.send(message); + return; + } + delayedForwards.push(() => ws.send(message)); + return; + } + + ws.send(message); + }); + }); + + return { + release() { + releaseRequested = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + waitForCreateRequest: () => createRequestSeen, + waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, + }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 62e67d598..27f118aff 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; -import { expect, test, type Page } from "./fixtures"; +import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { archiveWorkspaceFromDaemon, @@ -10,6 +10,7 @@ import { clickNewWorkspaceButton, connectNewWorkspaceDaemonClient, createWorktreeViaDaemon, + delayBrowserAgentCreatedStatus, expectComposerGithubAttachmentPill, expectStartingRefPickerTriggerPr, openNewWorkspaceComposer, @@ -28,108 +29,6 @@ import { workspaceLabelFromPath, } from "./helpers/workspace-ui"; -type WebSocketMessage = string | Buffer; - -function parseWebSocketJson(message: WebSocketMessage): unknown { - const rawMessage = typeof message === "string" ? message : message.toString("utf8"); - try { - return JSON.parse(rawMessage); - } catch { - return null; - } -} - -function getSessionMessage(message: WebSocketMessage): Record | null { - const envelope = parseWebSocketJson(message); - if (!envelope || typeof envelope !== "object") { - return null; - } - - const maybeEnvelope = envelope as { type?: unknown; message?: unknown }; - if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) { - return null; - } - if (typeof maybeEnvelope.message !== "object") { - return null; - } - - return maybeEnvelope.message as Record; -} - -function getStringField(input: Record, key: string): string | null { - const value = input[key]; - return typeof value === "string" ? value : null; -} - -async function delayBrowserAgentCreatedStatus(page: Page) { - const daemonPort = process.env.E2E_DAEMON_PORT; - if (!daemonPort) { - throw new Error("E2E_DAEMON_PORT is not set."); - } - - const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); - const createRequestIds = new Set(); - const delayedForwards: Array<() => void> = []; - let releaseRequested = false; - let resolveCreateRequest: (() => void) | null = null; - let resolveDelayedCreatedStatus: (() => void) | null = null; - const createRequestSeen = new Promise((resolve) => { - resolveCreateRequest = resolve; - }); - const delayedCreatedStatusSeen = new Promise((resolve) => { - resolveDelayedCreatedStatus = resolve; - }); - - await page.routeWebSocket(daemonPortPattern, (ws) => { - const server = ws.connectToServer(); - - ws.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - if (sessionMessage?.type === "create_agent_request") { - const requestId = getStringField(sessionMessage, "requestId"); - if (requestId) { - createRequestIds.add(requestId); - resolveCreateRequest?.(); - } - } - server.send(message); - }); - - server.onMessage((message) => { - const sessionMessage = getSessionMessage(message); - const payload = - sessionMessage?.type === "status" && typeof sessionMessage.payload === "object" - ? (sessionMessage.payload as Record) - : null; - const requestId = payload ? getStringField(payload, "requestId") : null; - - if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) { - resolveDelayedCreatedStatus?.(); - if (releaseRequested) { - ws.send(message); - return; - } - - delayedForwards.push(() => ws.send(message)); - return; - } - - ws.send(message); - }); - }); - - return { - release() { - releaseRequested = true; - for (const forward of delayedForwards.splice(0)) { - forward(); - } - }, - waitForCreateRequest: () => createRequestSeen, - waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen, - }; -} - test.describe("New workspace flow", () => { let client: Awaited>; const localWorkspaceIds = new Set(); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 3ff9900db..65f05a6eb 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -461,6 +461,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { onPress={handleEdit} style={styles.queueActionButton} accessibilityLabel="Edit queued message" + accessibilityRole="button" > @@ -468,6 +469,7 @@ function QueuedMessageRow({ item, onEdit, onSendNow }: QueuedMessageRowProps) { onPress={handleSendNow} style={QUEUE_SEND_BUTTON_STYLE} accessibilityLabel="Send queued message now" + accessibilityRole="button" >