Skip to content

Commit 6823e6b

Browse files
committed
fix(extension): acknowledge actual composer focus
1 parent 232908b commit 6823e6b

4 files changed

Lines changed: 194 additions & 9 deletions

File tree

‎apps/sim/app/extension/hooks/use-page-context.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ export function usePageContext(origins: string[], scopeKey: string) {
7676
let focusRequested = false
7777
const confirmFocus = () => {
7878
const bridge = bridgeRef.current
79-
if (!focusRequested || bridge?.sessionId !== sessionId || !document.hasFocus()) return
79+
if (
80+
!focusRequested ||
81+
bridge?.sessionId !== sessionId ||
82+
!document.hasFocus() ||
83+
!composerFocus?.current?.isFocused()
84+
) {
85+
return
86+
}
8087
focusRequested = false
8188
const message: AppToExtensionMessage = {
8289
channel: 'sim-extension',
@@ -98,7 +105,7 @@ export function usePageContext(origins: string[], scopeKey: string) {
98105
if (parsed.data.type === 'focus-composer') {
99106
if (bridgeRef.current?.sessionId === sessionId) {
100107
focusRequested = true
101-
composerFocus?.current?.()
108+
composerFocus?.current?.focus()
102109
confirmFocus()
103110
}
104111
return

‎apps/sim/app/extension/providers.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useSession } from '@/lib/auth/client'
77
import ExtensionLoading from '@/app/extension/loading'
88
import { ExtensionSignIn } from '@/app/extension/sign-in'
99
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
10-
import { ChatInputFocusContext } from '@/hooks/use-chat-input-focus'
10+
import { ChatInputFocusContext, type ChatInputFocusHandle } from '@/hooks/use-chat-input-focus'
1111

1212
interface ExtensionIdentity {
1313
origins: string[]
@@ -29,7 +29,7 @@ export function ExtensionProviders({
2929
modelSelectorEnabled,
3030
planModeEnabled,
3131
}: ExtensionProvidersProps) {
32-
const focusComposer = useRef<(() => void) | null>(null)
32+
const focusComposer = useRef<ChatInputFocusHandle | null>(null)
3333
const { data: session, isPending, error } = useSession()
3434
if (isPending) return <ExtensionLoading />
3535
const accessDenied = isApiClientError(error) && (error.status === 401 || error.status === 403)

‎apps/sim/hooks/use-chat-input-focus.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { createContext, type RefObject, useContext, useEffect, useImperativeHandle } from 'react'
22
import { isMobileViewport } from '@/hooks/use-is-mobile'
33

4-
export const ChatInputFocusContext = createContext<RefObject<(() => void) | null> | null>(null)
4+
export interface ChatInputFocusHandle {
5+
focus: () => void
6+
isFocused: () => boolean
7+
}
8+
9+
export const ChatInputFocusContext = createContext<RefObject<ChatInputFocusHandle | null> | null>(null)
510

611
interface UseChatInputFocusProps {
712
textareaRef: RefObject<HTMLTextAreaElement | null>
@@ -12,9 +17,13 @@ export function useChatInputFocus({ textareaRef }: UseChatInputFocusProps) {
1217
const requestedFocus = useContext(ChatInputFocusContext)
1318
useImperativeHandle(
1419
requestedFocus,
15-
() => () => {
16-
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
17-
},
20+
() => ({
21+
focus: () => {
22+
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
23+
},
24+
isFocused: () =>
25+
textareaRef.current !== null && document.activeElement === textareaRef.current,
26+
}),
1827
[textareaRef]
1928
)
2029
useEffect(() => {

‎apps/sim/scripts/test-browser-extension-e2e.ts‎

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type Locator,
1616
type Page,
1717
type Request,
18+
type Route,
1819
} from '@playwright/test'
1920
import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2021
import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
@@ -529,7 +530,7 @@ try {
529530
buffer: Buffer.from('This synthetic file belongs only to extension QA.'),
530531
})
531532
)
532-
const attachScreenshot = async (target: Locator) => {
533+
const screenshotFile = async () => {
533534
const name = 'drop-fixture.png'
534535
const png = resolve(temporary, name)
535536
await writeFile(
@@ -539,8 +540,146 @@ try {
539540
'base64'
540541
)
541542
)
543+
return { name, png }
544+
}
545+
const attachScreenshot = async (target: Locator) => {
546+
const { name, png } = await screenshotFile()
542547
return uploadFile(name, () => dropOnChat(shell, target, { files: [png] }))
543548
}
549+
const verifyPendingCaptureDrop = async (scope: 'workspace' | 'organization' | 'assistant') => {
550+
const app = frame()
551+
const input = app.getByRole('textbox', {
552+
name: scope === 'workspace' ? 'Message' : 'Ask Sim',
553+
exact: true,
554+
})
555+
await expect(input).toBeVisible()
556+
await expect(attachments()).toHaveCount(0)
557+
const greeting = app.getByRole('heading', { name: 'How can I help?', exact: true })
558+
await expect(greeting).toBeVisible()
559+
const priorPreference = await worker.evaluate(() =>
560+
chrome.storage.local.get('referenceCurrentPage')
561+
)
562+
// Hold the real transport result so the body drop overlaps pending submission deterministically.
563+
const gate = await app.evaluateHandle((parentOrigin) => {
564+
let held: MessageEvent<unknown> | undefined
565+
let released = false
566+
const receive = (event: MessageEvent<unknown>) => {
567+
const message = event.data
568+
if (
569+
released ||
570+
event.source !== window.parent ||
571+
event.origin !== parentOrigin ||
572+
typeof message !== 'object' ||
573+
message === null ||
574+
!('channel' in message) ||
575+
message.channel !== 'sim-extension' ||
576+
!('type' in message) ||
577+
message.type !== 'send-context'
578+
)
579+
return
580+
held = event
581+
event.stopImmediatePropagation()
582+
}
583+
window.addEventListener('message', receive, true)
584+
return {
585+
held: () => held !== undefined,
586+
release: () => {
587+
released = true
588+
if (held) window.dispatchEvent(held)
589+
held = undefined
590+
},
591+
dispose: () => window.removeEventListener('message', receive, true),
592+
}
593+
}, `chrome-extension://${extensionId}`)
594+
const endpoint = `${base.origin}/api/mothership/chat`
595+
const uploadEndpoint = `${base.origin}/api/files/uploads`
596+
const submitted: Record<string, unknown>[] = []
597+
let uploadAttempts = 0
598+
const interceptChat = (route: Route) => {
599+
if (route.request().method() !== 'POST') return route.continue()
600+
submitted.push(toRecord(route.request().postDataJSON()))
601+
injectedRequests.add(route.request())
602+
return route.fulfill({ status: 403, json: { error: 'QA pending capture admission check' } })
603+
}
604+
const interceptUpload = (route: Route) => {
605+
if (route.request().method() !== 'POST') return route.continue()
606+
uploadAttempts++
607+
injectedRequests.add(route.request())
608+
return route.fulfill({ status: 409, json: { error: 'QA unexpected pending upload' } })
609+
}
610+
await browserContext.route(endpoint, interceptChat)
611+
await browserContext.route(uploadEndpoint, interceptUpload)
612+
try {
613+
await app.getByRole('button', { name: 'Add resources', exact: true }).press('Enter')
614+
const preference = app.getByRole('menuitemcheckbox', { name: 'Reference current page' })
615+
await expect(preference).toBeVisible()
616+
if ((await preference.getAttribute('aria-checked')) !== 'true')
617+
await preference.press('Enter')
618+
else await preference.press('Escape')
619+
await expect(
620+
app.getByRole('button', { name: 'Attach current page', exact: true })
621+
).toHaveText('Current page')
622+
const draft = `Preserve the original ${scope} send during a screenshot drop.`
623+
await input.fill(draft)
624+
await source.bringToFront()
625+
await input.press('Enter')
626+
await expect.poll(() => gate.evaluate((capture) => capture.held())).toBe(true)
627+
const preparing = () =>
628+
input.evaluate((element) => Boolean(element.closest('[inert][aria-busy="true"]')))
629+
await expect.poll(preparing).toBe(true)
630+
const { name, png } = await screenshotFile()
631+
const pageCount = browserContext.pages().length
632+
const previousUrl = app.url()
633+
await dropOnChat(shell, greeting, { files: [png] })
634+
await app.evaluate(
635+
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
636+
)
637+
await screenshot(`pending-capture-drop-${scope}`)
638+
assert.equal(uploadAttempts, 0, `${scope}: a body drop started uploading during capture`)
639+
await expect(input).toHaveValue(draft)
640+
await expect(app.getByRole('button', { name: `Remove ${name}`, exact: true })).toHaveCount(0)
641+
assert.equal(submitted.length, 0, `${scope}: the held capture committed early`)
642+
assert.equal(app.url(), previousUrl)
643+
assert.equal(browserContext.pages().length, pageCount)
644+
await expect.poll(preparing).toBe(true)
645+
await gate.evaluate((capture) => capture.release())
646+
await expect.poll(() => submitted.length).toBe(1)
647+
const body = submitted[0]
648+
assert(String(body.message).startsWith(draft))
649+
assert.equal(
650+
body[scope === 'workspace' ? 'workspaceId' : 'organizationId'],
651+
scope === 'workspace' ? alphaId : organizationId
652+
)
653+
if (scope === 'assistant') assert.equal(body.mode, 'assistant')
654+
const pages = submittedPages(body)
655+
assert.equal(pages.length, 1)
656+
assert(pages[0].text.includes('ORCHID-482'))
657+
assert.equal(pages[0].url, `${fixtureOrigin}/page`)
658+
assert.equal(body.fileAttachments, undefined)
659+
await expect.poll(preparing).toBe(false)
660+
await expect(
661+
app.getByText('Your draft changed while the page was being read. Send again to continue.', {
662+
exact: true,
663+
})
664+
).toHaveCount(0)
665+
assert.equal(uploadAttempts, 0)
666+
await browserContext.unroute(uploadEndpoint, interceptUpload)
667+
await attachScreenshot(greeting)
668+
await screenshot(`pending-capture-drop-${scope}-recovered`)
669+
await app.getByRole('button', { name: `Remove ${name}`, exact: true }).click()
670+
assert.equal(submitted.length, 1, `${scope}: recovering drops duplicated the turn`)
671+
} finally {
672+
await gate.evaluate((capture) => capture.release()).catch(() => {})
673+
await gate.evaluate((capture) => capture.dispose()).catch(() => {})
674+
await gate.dispose()
675+
await browserContext.unroute(endpoint, interceptChat)
676+
await browserContext.unroute(uploadEndpoint, interceptUpload)
677+
await worker.evaluate(async (previous) => {
678+
if ('referenceCurrentPage' in previous) await chrome.storage.local.set(previous)
679+
else await chrome.storage.local.remove('referenceCurrentPage')
680+
}, priorPreference)
681+
}
682+
}
544683
await checkNative(
545684
'Capture, preview, removal, and the three-page cap use the real composer',
546685
async () => {
@@ -1703,6 +1842,22 @@ try {
17031842
}
17041843
}
17051844
)
1845+
await checkNative(
1846+
'Pending page reference freezes body file drops in workspace and organization chat',
1847+
async () => {
1848+
for (const scope of ['workspace', 'organization'] as const) {
1849+
await shell.close()
1850+
panel = await openPanel()
1851+
shell = panel
1852+
await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`)
1853+
await observeCaptureReceipts()
1854+
await navigate(
1855+
`${base.origin}/extension/${scope === 'workspace' ? alphaId : `o/${organizationId}`}`
1856+
)
1857+
await verifyPendingCaptureDrop(scope)
1858+
}
1859+
}
1860+
)
17061861
await checkNative(
17071862
'A screenshot dropped on the workspace or organization chat body uploads once and can be sent by itself',
17081863
async () => {
@@ -2228,6 +2383,20 @@ try {
22282383
} else await shell.unroute('**/api/mothership/chat')
22292384
}
22302385
)
2386+
await checkAccess(
2387+
'Restricted Assistant freezes body file drops while a page reference is pending',
2388+
async () => {
2389+
await shell.close()
2390+
panel = await openPanel()
2391+
shell = panel
2392+
if (!liveChat) await shell.clock.install()
2393+
await shell.goto(`chrome-extension://${extensionId}/sidepanel.html`)
2394+
await observeCaptureReceipts()
2395+
await navigate(`${base.origin}/extension/o/${organizationId}`)
2396+
await expect(frame().getByRole('button', { name: 'Attach image', exact: true })).toBeVisible()
2397+
await verifyPendingCaptureDrop('assistant')
2398+
}
2399+
)
22312400
await checkAccess(
22322401
'Revoking organization membership removes the live composer and pending captures',
22332402
async () => {

0 commit comments

Comments
 (0)