Skip to content
Merged
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
16 changes: 14 additions & 2 deletions apps/extension/scripts/test-e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,9 +1282,21 @@ try {
reply.type === 'send-context' && reply.snapshot.text.includes('visible-reference-482'),
`${stale} intent cancelled the current reference: ${reply.type}`
)
const replayKey = `${key}:idle-replay`
const keys = [key, replayKey]
// A fresh storage event drains deferred intents without depending on the periodic sweep's phase.
await worker.evaluate(({ key, intent }) => chrome.storage.session.set({ [key]: intent }), {
key: replayKey,
intent,
})
await expect
.poll(() =>
worker.evaluate(async (key) => !(key in (await chrome.storage.session.get(key))), key)
.poll(
() =>
worker.evaluate(async (keys) => {
const stored = await chrome.storage.session.get(keys)
return keys.every((key) => !(key in stored))
}, keys),
{ message: `${stale}: idle storage delivery drains deferred stale intents` }
)
.toBe(true)
await settleMessages()
Expand Down
11 changes: 9 additions & 2 deletions apps/sim/app/extension/hooks/use-page-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ export function usePageContext(origins: string[], scopeKey: string) {
let focusRequested = false
const confirmFocus = () => {
const bridge = bridgeRef.current
if (!focusRequested || bridge?.sessionId !== sessionId || !document.hasFocus()) return
if (
!focusRequested ||
bridge?.sessionId !== sessionId ||
!document.hasFocus() ||
!composerFocus?.current?.isFocused()
) {
return
}
focusRequested = false
const message: AppToExtensionMessage = {
channel: 'sim-extension',
Expand All @@ -98,7 +105,7 @@ export function usePageContext(origins: string[], scopeKey: string) {
if (parsed.data.type === 'focus-composer') {
if (bridgeRef.current?.sessionId === sessionId) {
focusRequested = true
composerFocus?.current?.()
composerFocus?.current?.focus()
confirmFocus()
}
return
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/extension/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useSession } from '@/lib/auth/client'
import ExtensionLoading from '@/app/extension/loading'
import { ExtensionSignIn } from '@/app/extension/sign-in'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { ChatInputFocusContext } from '@/hooks/use-chat-input-focus'
import { ChatInputFocusContext, type ChatInputFocusHandle } from '@/hooks/use-chat-input-focus'

interface ExtensionIdentity {
origins: string[]
Expand All @@ -29,7 +29,7 @@ export function ExtensionProviders({
modelSelectorEnabled,
planModeEnabled,
}: ExtensionProvidersProps) {
const focusComposer = useRef<(() => void) | null>(null)
const focusComposer = useRef<ChatInputFocusHandle | null>(null)
const { data: session, isPending, error } = useSession()
if (isPending) return <ExtensionLoading />
const accessDenied = isApiClientError(error) && (error.status === 401 || error.status === 403)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats'
import type { PrepareChatSubmission } from '@/hooks/use-composer-submission'
import { type PrepareChatSubmission, useComposerSubmission } from '@/hooks/use-composer-submission'
import { useHydrated } from '@/hooks/use-hydrated'
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
import type { ChatContext } from '@/stores/panel'
Expand Down Expand Up @@ -289,8 +289,10 @@ function OrganizationConversationContent({
}
}
const fileDropTarget = useRef<HTMLElement>(null)
const submission = useComposerSubmission({ mode: requestMode })
const files = useFileAttachments({
fileDropTarget: compact ? fileDropTarget : undefined,
disabled: submission.pending,
userId: session?.user?.id,
organizationId: organization.id,
requestMode,
Expand Down Expand Up @@ -458,6 +460,7 @@ function OrganizationConversationContent({
const composer = (
<Composer
requestMode={requestMode}
submission={submission}
compact={Boolean(panelOptions)}
attachments={panelOptions?.attachments}
resourceActions={panelOptions?.resourceActions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { useComposerSubmission } from '@/hooks/use-composer-submission'
import type { useSpeechToText } from '@/hooks/use-speech-to-text'
import {
useMothershipEffortStore,
Expand Down Expand Up @@ -202,14 +203,17 @@ async function render(
) {
function Harness() {
const [value, setValue] = useState(initialValue)
const submission = useComposerSubmission({ mode: requestMode })
const files = useFileAttachments({
userId: 'user-a',
organizationId: 'organization-a',
requestMode,
disabled: submission.pending,
})
return (
<Composer
requestMode={requestMode}
submission={submission}
restoredContexts={controls.restoredContexts}
value={value}
files={files}
Expand Down Expand Up @@ -292,14 +296,17 @@ it.each([false, true])(
: []),
])
}
const submission = useComposerSubmission({ mode: 'agent' })
const files = useFileAttachments({
userId: 'user-a',
organizationId: 'organization-a',
requestMode: 'agent',
disabled: submission.pending,
})
return (
<Composer
requestMode='agent'
submission={submission}
value={value}
onChange={setValue}
restoredContexts={contexts}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ import { getSkillsQueryOptions } from '@/hooks/queries/skills'
import { useWorkspacesQuery } from '@/hooks/queries/workspace'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
import {
type PendingComposerSubmission,
import type {
PendingComposerSubmission,
useComposerSubmission,
} from '@/hooks/use-composer-submission'
import { useVoiceInput } from '@/hooks/use-voice-input'
Expand All @@ -50,6 +50,7 @@ interface ComposerProps {
onPlanChange?: () => void
value: string
files: ReturnType<typeof useFileAttachments>
submission: ReturnType<typeof useComposerSubmission>
/** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */
isInitialView: boolean
isSending: boolean
Expand All @@ -76,6 +77,7 @@ export function Composer({
onPlanChange,
value,
files,
submission,
isInitialView,
isSending,
onChange,
Expand Down Expand Up @@ -174,9 +176,7 @@ export function Composer({
)
const placeholder = isInitialView && !compact ? animatedPlaceholder : 'Send message to Sim'

const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
mode: requestMode,
})
const { pending: preparingSubmission, accept: acceptSubmission } = submission
const submit = () => {
if (attachedFilesRef.current.some((file) => file.uploading)) return
const hasPayload =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,15 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI

const prevDefaultValueRef = useRef(defaultValue)

const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
scope: draftScopeKey,
mode: requestMode,
})
const files = useFileAttachments({
fileDropTarget,
userId,
workspaceId,
disabled: preparingSubmission,
})
const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key)
const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading)
Expand Down Expand Up @@ -535,10 +540,6 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
filesRef.current = { ...filesRef.current, attachedFiles: [] }
}, [resetTranscript, history.reset])

const { pending: preparingSubmission, accept: acceptSubmission } = useComposerSubmission({
scope: draftScopeKey,
mode: requestMode,
})
const handleSubmit = useCallback(() => {
if (chatPlanRequired) {
useChatPlanModalStore.getState().open(workspaceId)
Expand Down
20 changes: 16 additions & 4 deletions apps/sim/hooks/use-chat-input-focus.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { createContext, type RefObject, useContext, useEffect, useImperativeHandle } from 'react'
import { isMobileViewport } from '@/hooks/use-is-mobile'

export const ChatInputFocusContext = createContext<RefObject<(() => void) | null> | null>(null)
/** Requests textarea focus without interrupting dialogs and reports whether that textarea is active. */
export interface ChatInputFocusHandle {
focus: () => void
isFocused: () => boolean
}
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

export const ChatInputFocusContext = createContext<RefObject<ChatInputFocusHandle | null> | null>(
null
)

interface UseChatInputFocusProps {
textareaRef: RefObject<HTMLTextAreaElement | null>
Expand All @@ -12,9 +20,13 @@ export function useChatInputFocus({ textareaRef }: UseChatInputFocusProps) {
const requestedFocus = useContext(ChatInputFocusContext)
useImperativeHandle(
requestedFocus,
() => () => {
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
},
() => ({
focus: () => {
if (!document.querySelector('[role="dialog"], dialog[open]')) textareaRef.current?.focus()
},
isFocused: () =>
textareaRef.current !== null && document.activeElement === textareaRef.current,
}),
[textareaRef]
)
useEffect(() => {
Expand Down
103 changes: 94 additions & 9 deletions apps/sim/scripts/browser-extension-e2e/chat-file-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,102 @@ export async function dropOnChat(page: Page, target: Locator, payload: ChatDropP
],
dragOperationsMask: 1,
}
const session = await page.context().newCDPSession(page)
const observed = await target.evaluateHandle((element) => {
const events: {
event: DragEvent
receipt: {
type: string
trusted: boolean
tag: string | null
intendedTarget: boolean
chatBody: boolean
inert: boolean
files: number
types: string[]
defaultPrevented: boolean
dropEffect: string | null
x: number
y: number
}
}[] = []
const receive = (event: DragEvent) => {
const target = event.target instanceof Element ? event.target : null
const receipt = {
type: event.type,
trusted: event.isTrusted,
tag: target?.tagName ?? null,
intendedTarget: target !== null && element.contains(target),
chatBody: Boolean(target?.closest('main')),
inert: Boolean(target?.closest('[inert]')),
files: event.dataTransfer?.files.length ?? 0,
types: Array.from(event.dataTransfer?.types ?? []),
defaultPrevented: event.defaultPrevented,
dropEffect: event.dataTransfer?.dropEffect ?? null,
x: event.clientX,
y: event.clientY,
}
events.push({ event, receipt })
}
for (const type of ['dragenter', 'dragover', 'drop'] as const)
element.ownerDocument.addEventListener(type, receive, true)
const rect = element.getBoundingClientRect()
const x = rect.x + rect.width / 2
const y = rect.y + rect.height / 2
const hit = element.ownerDocument.elementFromPoint(x, y)
return {
readEvents: () =>
events.map(({ event, receipt }) => ({
...receipt,
defaultPrevented: event.defaultPrevented,
dropEffect: event.dataTransfer?.dropEffect ?? null,
})),
frameTarget: {
x,
y,
width: window.innerWidth,
height: window.innerHeight,
tag: hit?.tagName ?? null,
intendedTarget: hit !== null && element.contains(hit),
inert: Boolean(hit?.closest('[inert]')),
},
connected: () => element.isConnected,
dispose: () => {
for (const type of ['dragenter', 'dragover', 'drop'] as const)
element.ownerDocument.removeEventListener(type, receive, true)
},
}
})
try {
for (const type of ['dragEnter', 'dragOver', 'drop'] as const) {
await session.send('Input.dispatchDragEvent', {
type,
x: box.x + box.width / 2,
y: box.y + box.height / 2,
data,
})
const session = await page.context().newCDPSession(page)
try {
const { windowId, bounds } = await session.send('Browser.getWindowForTarget')
for (const type of ['dragEnter', 'dragOver', 'drop'] as const) {
await session.send('Input.dispatchDragEvent', {
type,
x: box.x + box.width / 2,
y: box.y + box.height / 2,
data,
})
}
return {
box,
nativeWindow: { windowId, bounds },
viewport: page.viewportSize(),
topHitTag: await page.evaluate(
({ x, y }) => document.elementFromPoint(x, y)?.tagName ?? null,
{ x: box.x + box.width / 2, y: box.y + box.height / 2 }
),
...(await observed.evaluate((state) => ({
events: state.readEvents(),
frameTarget: state.frameTarget,
connected: state.connected(),
}))),
}
} finally {
await session.detach()
}
} finally {
await session.detach()
await observed.evaluate((state) => state.dispose()).catch(() => {})
await observed.dispose()
}
}
Loading
Loading