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
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@
* @vitest-environment jsdom
*/
import { act } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'

const hoverState = vi.hoisted(() => ({ isOpen: false }))

vi.mock('next/link', () => ({
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
default: ({
href,
children,
prefetch: _prefetch,
...props
}: {
href: string
children: React.ReactNode
prefetch?: boolean
}) => (
<a href={href} {...props}>
{children}
</a>
Expand All @@ -36,6 +46,8 @@ const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({

let container: HTMLDivElement
let root: Root
let queryClient: QueryClient
let prefetchQuery: ReturnType<typeof vi.spyOn>

beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
Expand All @@ -48,6 +60,8 @@ beforeEach(() => {
}
)
hoverState.isOpen = false
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
Expand All @@ -56,22 +70,25 @@ beforeEach(() => {
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
queryClient.clear()
vi.unstubAllGlobals()
})

async function render(props: Partial<Parameters<typeof ChatsSection>[0]> = {}) {
await act(async () => {
root.render(
<ChatsSection
chats={CHATS}
isLoading={false}
isCollapsed={false}
pathname={null}
menuOpenHref={null}
onContextMenu={() => {}}
onMoreClick={() => {}}
{...props}
/>
<QueryClientProvider client={queryClient}>
<ChatsSection
chats={CHATS}
isLoading={false}
isCollapsed={false}
pathname={null}
menuOpenHref={null}
onContextMenu={() => {}}
onMoreClick={() => {}}
{...props}
/>
</QueryClientProvider>
)
})
}
Expand Down Expand Up @@ -103,6 +120,28 @@ describe('ChatsSection', () => {
await act(async () => button?.click())

expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2')
expect(prefetchQuery).not.toHaveBeenCalled()
})

it.each([false, true])(
'prefetches focused destination history with collapsed=%s',
async (isCollapsed) => {
hoverState.isOpen = isCollapsed
await render({ isCollapsed })
prefetchQuery.mockClear()
const link = document.body.querySelector<HTMLAnchorElement>('a[href="/o/org-1/chat/chat-3"]')!
await act(async () => link.focus())
expect(prefetchQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['mothership-chats', 'detail', 'chat-3'] })
)
}
)

it('does not prefetch the active conversation', async () => {
await render({ pathname: '/o/org-1/chat/chat-3' })
const link = container.querySelector<HTMLAnchorElement>('a[href="/o/org-1/chat/chat-3"]')!
await act(async () => link.focus())
expect(prefetchQuery).not.toHaveBeenCalled()
})

it('shows the empty state when there are no chats', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn'
import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons'
import Link from 'next/link'
import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import {
ChatNavigationLink,
CollapsedSidebarMenu,
SidebarSection,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
Expand Down Expand Up @@ -41,8 +41,10 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick
const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread))

return (
<Link
<ChatNavigationLink
href={chat.href}
chatId={chat.id}
isCurrentRoute={isCurrentRoute}
className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })}
onContextMenu={(e) => onContextMenu(e, chat.href)}
>
Expand Down Expand Up @@ -83,7 +85,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick
<MoreHorizontal className='size-[14px] text-[var(--text-icon)]' />
</button>
</div>
</Link>
</ChatNavigationLink>
)
}

Expand Down Expand Up @@ -140,13 +142,18 @@ export function ChatsSection({
const isCurrentRoute = pathname === chat.href
return (
<DropdownMenuItem key={chat.id} asChild active={isCurrentRoute}>
<Link href={chat.href} onContextMenu={(e) => onContextMenu(e, chat.href)}>
<ChatNavigationLink
href={chat.href}
chatId={chat.id}
isCurrentRoute={isCurrentRoute}
onContextMenu={(e) => onContextMenu(e, chat.href)}
>
<ConversationListItem
title={chat.name}
isActive={Boolean(chat.isActive)}
isUnread={Boolean(chat.isUnread) && !isCurrentRoute}
/>
</Link>
</ChatNavigationLink>
</DropdownMenuItem>
)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
'use client'

import { useRef } from 'react'
import { Button, cn } from '@sim/emcn'
import { ArrowUp } from '@sim/emcn/icons'
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'

const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors'
const SEND_BUTTON_ACTIVE =
Expand Down Expand Up @@ -32,6 +34,8 @@ export function Composer({
onSubmit,
onStop,
}: ComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
useChatInputFocus({ textareaRef })
const canSubmit = value.trim().length > 0
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
Expand All @@ -50,6 +54,7 @@ export function Composer({
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
Expand Down
48 changes: 47 additions & 1 deletion apps/sim/app/o/[organizationId]/home/organization-home.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
import { act, type ComponentProps } from 'react'
import { act, type ComponentProps, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -96,6 +96,52 @@ describe('organization home', () => {
expect(container.textContent).not.toContain('Get started')
expect(mocks.consume).not.toHaveBeenCalled()
})
it('keeps the composer available when messages exist while history is pending', async () => {
mocks.chat.mockReturnValue({
messages: [{ id: 'message-a', role: 'user', content: 'A question' }],
isChatHistoryPending: true,
sendMessage: mocks.send,
})
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
expect(mocks.renderer).toHaveBeenCalledWith(
expect.objectContaining({ isLoading: false }),
undefined
)
})
it('isolates conversation state when switching cached chats', async () => {
mocks.chat.mockReturnValue({
messages: [],
isChatHistoryPending: false,
sendMessage: mocks.send,
})
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
await act(async () => composerProps().onChange('A draft for chat A'))
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
expect(composerProps().value).toBe('A draft for chat A')
await act(async () => root.render(<OrganizationHome chatId='chat-b' />))
expect(composerProps().value).toBe('')
expect(mocks.chat).toHaveBeenLastCalledWith({ organizationId: 'organization-a' }, 'chat-b')
expect(mocks.send).not.toHaveBeenCalled()
})
it('preserves the conversation when the first send adopts a chat ID', async () => {
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
await act(async () => root.render(<OrganizationHome />))
await act(async () => composerProps().onChange('A follow-up draft'))
mocks.chat.mockReturnValue({
messages: [{ id: 'message-a', role: 'user', content: 'First question' }],
resolvedChatId: 'chat-a',
isChatHistoryPending: true,
isSending: true,
sendMessage: mocks.send,
})
await act(async () => root.render(<OrganizationHome />))
expect(composerProps().value).toBe('A follow-up draft')
expect(mocks.renderer).toHaveBeenCalledWith(
expect.objectContaining({ chatId: 'chat-a', isLoading: false, isSending: true }),
undefined
)
})
it.each([
{ isAdmin: true, integrationHref: '/o/organization-a/settings/integrations' },
{ isAdmin: false, integrationHref: '/o/organization-a/integrations' },
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/o/[organizationId]/home/organization-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ interface OrganizationHomeProps {

/** Search and private Assistant chats for the routed organization. */
export function OrganizationHome(props: OrganizationHomeProps) {
const { searchAccess } = useOrganizationContext()
const { organization, searchAccess } = useOrganizationContext()
if (!searchAccess.memberScoped) return null
return <OrganizationHomeContent {...props} />
return <OrganizationHomeContent key={`${organization.id}:${props.chatId ?? 'new'}`} {...props} />
}

function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
Expand Down Expand Up @@ -82,7 +82,7 @@ function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
messages={chat.messages}
isSending={chat.isSending}
isReconnecting={chat.isReconnecting}
isLoading={Boolean(chatId) && chat.isChatHistoryPending}
isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending}
onSubmit={send}
onStopGeneration={() => {
void chat.stopGeneration()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { type SpeechToTextError, useSpeechToText } from '@/hooks/use-speech-to-text'
import { type DraftPayload, useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
Expand All @@ -50,16 +51,6 @@ export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/ty

const logger = createLogger('UserInput')

/**
* Whether the element is somewhere the user could be typing. Focusing the composer on mount
* must not steal focus from another field, but may take it from a link or button — opening a
* chat leaves the sidebar link focused, and the composer should win.
*/
function isTextEntry(element: HTMLElement): boolean {
const tag = element.tagName
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable
}

interface UserInputProps {
defaultValue?: string
draftScopeKey?: string
Expand Down Expand Up @@ -168,6 +159,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
const editorRef = useRef(editor)
editorRef.current = editor
const textareaRef = editor.textareaRef
useChatInputFocus({ textareaRef })

/**
* Attaches context chips pushed from elsewhere in the app (browser/terminal
Expand Down Expand Up @@ -549,16 +541,6 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
wasSendingRef.current = isSending
}, [isSending, textareaRef])

useEffect(() => {
const raf = window.requestAnimationFrame(() => {
if (!document.hasFocus()) return
const active = document.activeElement
if (active instanceof HTMLElement && isTextEntry(active)) return
textareaRef.current?.focus()
})
return () => window.cancelAnimationFrame(raf)
}, [textareaRef])

/**
* Menu rows are excluded alongside buttons: the mode switcher's items are
* portaled, so their clicks still bubble here through the React tree.
Expand Down
Loading
Loading