Skip to content

Commit 965f336

Browse files
authored
fix(extension): preserve composer focus and pending page reads (#9657)
* fix(extension): preserve composer focus and pending page reads * test(extension): isolate reference scenarios and trace native drops * fix(extension): stabilize browser recovery checks * chore(extension): document composer focus handle * test(extension): verify pending file drag delivery * test(extension): await persisted attachment cleanup
1 parent b5660ac commit 965f336

10 files changed

Lines changed: 646 additions & 40 deletions

File tree

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

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,9 +1282,21 @@ try {
12821282
reply.type === 'send-context' && reply.snapshot.text.includes('visible-reference-482'),
12831283
`${stale} intent cancelled the current reference: ${reply.type}`
12841284
)
1285+
const replayKey = `${key}:idle-replay`
1286+
const keys = [key, replayKey]
1287+
// A fresh storage event drains deferred intents without depending on the periodic sweep's phase.
1288+
await worker.evaluate(({ key, intent }) => chrome.storage.session.set({ [key]: intent }), {
1289+
key: replayKey,
1290+
intent,
1291+
})
12851292
await expect
1286-
.poll(() =>
1287-
worker.evaluate(async (key) => !(key in (await chrome.storage.session.get(key))), key)
1293+
.poll(
1294+
() =>
1295+
worker.evaluate(async (keys) => {
1296+
const stored = await chrome.storage.session.get(keys)
1297+
return keys.every((key) => !(key in stored))
1298+
}, keys),
1299+
{ message: `${stale}: idle storage delivery drains deferred stale intents` }
12881300
)
12891301
.toBe(true)
12901302
await settleMessages()

‎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/app/o/[organizationId]/components/organization-conversation.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-
6767
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
6868
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
6969
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
70-
import type { PrepareChatSubmission } from '@/hooks/use-composer-submission'
70+
import { type PrepareChatSubmission, useComposerSubmission } from '@/hooks/use-composer-submission'
7171
import { useHydrated } from '@/hooks/use-hydrated'
7272
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
7373
import type { ChatContext } from '@/stores/panel'
@@ -289,8 +289,10 @@ function OrganizationConversationContent({
289289
}
290290
}
291291
const fileDropTarget = useRef<HTMLElement>(null)
292+
const submission = useComposerSubmission({ mode: requestMode })
292293
const files = useFileAttachments({
293294
fileDropTarget: compact ? fileDropTarget : undefined,
295+
disabled: submission.pending,
294296
userId: session?.user?.id,
295297
organizationId: organization.id,
296298
requestMode,
@@ -458,6 +460,7 @@ function OrganizationConversationContent({
458460
const composer = (
459461
<Composer
460462
requestMode={requestMode}
463+
submission={submission}
461464
compact={Boolean(panelOptions)}
462465
attachments={panelOptions?.attachments}
463466
resourceActions={panelOptions?.resourceActions}

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1515
import { createRoot, type Root } from 'react-dom/client'
1616
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
17+
import { useComposerSubmission } from '@/hooks/use-composer-submission'
1718
import type { useSpeechToText } from '@/hooks/use-speech-to-text'
1819
import {
1920
useMothershipEffortStore,
@@ -202,14 +203,17 @@ async function render(
202203
) {
203204
function Harness() {
204205
const [value, setValue] = useState(initialValue)
206+
const submission = useComposerSubmission({ mode: requestMode })
205207
const files = useFileAttachments({
206208
userId: 'user-a',
207209
organizationId: 'organization-a',
208210
requestMode,
211+
disabled: submission.pending,
209212
})
210213
return (
211214
<Composer
212215
requestMode={requestMode}
216+
submission={submission}
213217
restoredContexts={controls.restoredContexts}
214218
value={value}
215219
files={files}
@@ -292,14 +296,17 @@ it.each([false, true])(
292296
: []),
293297
])
294298
}
299+
const submission = useComposerSubmission({ mode: 'agent' })
295300
const files = useFileAttachments({
296301
userId: 'user-a',
297302
organizationId: 'organization-a',
298303
requestMode: 'agent',
304+
disabled: submission.pending,
299305
})
300306
return (
301307
<Composer
302308
requestMode='agent'
309+
submission={submission}
303310
value={value}
304311
onChange={setValue}
305312
restoredContexts={contexts}

‎apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ import { getSkillsQueryOptions } from '@/hooks/queries/skills'
3838
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
3939
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
4040
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
41-
import {
42-
type PendingComposerSubmission,
41+
import type {
42+
PendingComposerSubmission,
4343
useComposerSubmission,
4444
} from '@/hooks/use-composer-submission'
4545
import { useVoiceInput } from '@/hooks/use-voice-input'
@@ -50,6 +50,7 @@ interface ComposerProps {
5050
onPlanChange?: () => void
5151
value: string
5252
files: ReturnType<typeof useFileAttachments>
53+
submission: ReturnType<typeof useComposerSubmission>
5354
/** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */
5455
isInitialView: boolean
5556
isSending: boolean
@@ -76,6 +77,7 @@ export function Composer({
7677
onPlanChange,
7778
value,
7879
files,
80+
submission,
7981
isInitialView,
8082
isSending,
8183
onChange,
@@ -174,9 +176,7 @@ export function Composer({
174176
)
175177
const placeholder = isInitialView && !compact ? animatedPlaceholder : 'Send message to Sim'
176178

177-
const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
178-
mode: requestMode,
179-
})
179+
const { pending: preparingSubmission, accept: acceptSubmission } = submission
180180
const submit = () => {
181181
if (attachedFilesRef.current.some((file) => file.uploading)) return
182182
const hasPayload =

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,15 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
133133

134134
const prevDefaultValueRef = useRef(defaultValue)
135135

136+
const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
137+
scope: draftScopeKey,
138+
mode: requestMode,
139+
})
136140
const files = useFileAttachments({
137141
fileDropTarget,
138142
userId,
139143
workspaceId,
144+
disabled: preparingSubmission,
140145
})
141146
const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
142147
const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading)
@@ -535,10 +540,6 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
535540
filesRef.current = { ...filesRef.current, attachedFiles: [] }
536541
}, [resetTranscript, history.reset])
537542

538-
const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
539-
scope: draftScopeKey,
540-
mode: requestMode,
541-
})
542543
const handleSubmit = useCallback(() => {
543544
if (chatPlanRequired) {
544545
useChatPlanModalStore.getState().open(workspaceId)

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
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+
/** Requests textarea focus without interrupting dialogs and reports whether that textarea is active. */
5+
export interface ChatInputFocusHandle {
6+
focus: () => void
7+
isFocused: () => boolean
8+
}
9+
10+
export const ChatInputFocusContext = createContext<RefObject<ChatInputFocusHandle | null> | null>(
11+
null
12+
)
513

614
interface UseChatInputFocusProps {
715
textareaRef: RefObject<HTMLTextAreaElement | null>
@@ -12,9 +20,13 @@ export function useChatInputFocus({ textareaRef }: UseChatInputFocusProps) {
1220
const requestedFocus = useContext(ChatInputFocusContext)
1321
useImperativeHandle(
1422
requestedFocus,
15-
() => () => {
16-
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
17-
},
23+
() => ({
24+
focus: () => {
25+
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
26+
},
27+
isFocused: () =>
28+
textareaRef.current !== null && document.activeElement === textareaRef.current,
29+
}),
1830
[textareaRef]
1931
)
2032
useEffect(() => {

‎apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts‎

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,102 @@ export async function dropOnChat(page: Page, target: Locator, payload: ChatDropP
2020
],
2121
dragOperationsMask: 1,
2222
}
23-
const session = await page.context().newCDPSession(page)
23+
const observed = await target.evaluateHandle((element) => {
24+
const events: {
25+
event: DragEvent
26+
receipt: {
27+
type: string
28+
trusted: boolean
29+
tag: string | null
30+
intendedTarget: boolean
31+
chatBody: boolean
32+
inert: boolean
33+
files: number
34+
types: string[]
35+
defaultPrevented: boolean
36+
dropEffect: string | null
37+
x: number
38+
y: number
39+
}
40+
}[] = []
41+
const receive = (event: DragEvent) => {
42+
const target = event.target instanceof Element ? event.target : null
43+
const receipt = {
44+
type: event.type,
45+
trusted: event.isTrusted,
46+
tag: target?.tagName ?? null,
47+
intendedTarget: target !== null && element.contains(target),
48+
chatBody: Boolean(target?.closest('main')),
49+
inert: Boolean(target?.closest('[inert]')),
50+
files: event.dataTransfer?.files.length ?? 0,
51+
types: Array.from(event.dataTransfer?.types ?? []),
52+
defaultPrevented: event.defaultPrevented,
53+
dropEffect: event.dataTransfer?.dropEffect ?? null,
54+
x: event.clientX,
55+
y: event.clientY,
56+
}
57+
events.push({ event, receipt })
58+
}
59+
for (const type of ['dragenter', 'dragover', 'drop'] as const)
60+
element.ownerDocument.addEventListener(type, receive, true)
61+
const rect = element.getBoundingClientRect()
62+
const x = rect.x + rect.width / 2
63+
const y = rect.y + rect.height / 2
64+
const hit = element.ownerDocument.elementFromPoint(x, y)
65+
return {
66+
readEvents: () =>
67+
events.map(({ event, receipt }) => ({
68+
...receipt,
69+
defaultPrevented: event.defaultPrevented,
70+
dropEffect: event.dataTransfer?.dropEffect ?? null,
71+
})),
72+
frameTarget: {
73+
x,
74+
y,
75+
width: window.innerWidth,
76+
height: window.innerHeight,
77+
tag: hit?.tagName ?? null,
78+
intendedTarget: hit !== null && element.contains(hit),
79+
inert: Boolean(hit?.closest('[inert]')),
80+
},
81+
connected: () => element.isConnected,
82+
dispose: () => {
83+
for (const type of ['dragenter', 'dragover', 'drop'] as const)
84+
element.ownerDocument.removeEventListener(type, receive, true)
85+
},
86+
}
87+
})
2488
try {
25-
for (const type of ['dragEnter', 'dragOver', 'drop'] as const) {
26-
await session.send('Input.dispatchDragEvent', {
27-
type,
28-
x: box.x + box.width / 2,
29-
y: box.y + box.height / 2,
30-
data,
31-
})
89+
const session = await page.context().newCDPSession(page)
90+
try {
91+
const { windowId, bounds } = await session.send('Browser.getWindowForTarget')
92+
for (const type of ['dragEnter', 'dragOver', 'drop'] as const) {
93+
await session.send('Input.dispatchDragEvent', {
94+
type,
95+
x: box.x + box.width / 2,
96+
y: box.y + box.height / 2,
97+
data,
98+
})
99+
}
100+
return {
101+
box,
102+
nativeWindow: { windowId, bounds },
103+
viewport: page.viewportSize(),
104+
topHitTag: await page.evaluate(
105+
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null,
106+
{ x: box.x + box.width / 2, y: box.y + box.height / 2 }
107+
),
108+
...(await observed.evaluate((state) => ({
109+
events: state.readEvents(),
110+
frameTarget: state.frameTarget,
111+
connected: state.connected(),
112+
}))),
113+
}
114+
} finally {
115+
await session.detach()
32116
}
33117
} finally {
34-
await session.detach()
118+
await observed.evaluate((state) => state.dispose()).catch(() => {})
119+
await observed.dispose()
35120
}
36121
}

0 commit comments

Comments
 (0)