Skip to content

Commit e7faad3

Browse files
authored
fix(chat): align organization focus and loading with workspace (#7647)
1 parent 349353d commit e7faad3

8 files changed

Lines changed: 250 additions & 40 deletions

File tree

apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22
* @vitest-environment jsdom
33
*/
44
import { act } from 'react'
5+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
56
import { createRoot, type Root } from 'react-dom/client'
67
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
78
import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks'
89

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

1112
vi.mock('next/link', () => ({
12-
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
13+
default: ({
14+
href,
15+
children,
16+
prefetch: _prefetch,
17+
...props
18+
}: {
19+
href: string
20+
children: React.ReactNode
21+
prefetch?: boolean
22+
}) => (
1323
<a href={href} {...props}>
1424
{children}
1525
</a>
@@ -36,6 +46,8 @@ const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({
3646

3747
let container: HTMLDivElement
3848
let root: Root
49+
let queryClient: QueryClient
50+
let prefetchQuery: ReturnType<typeof vi.spyOn>
3951

4052
beforeEach(() => {
4153
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@@ -48,6 +60,8 @@ beforeEach(() => {
4860
}
4961
)
5062
hoverState.isOpen = false
63+
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
64+
prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue()
5165
container = document.createElement('div')
5266
document.body.appendChild(container)
5367
root = createRoot(container)
@@ -56,22 +70,25 @@ beforeEach(() => {
5670
afterEach(async () => {
5771
await act(async () => root.unmount())
5872
container.remove()
73+
queryClient.clear()
5974
vi.unstubAllGlobals()
6075
})
6176

6277
async function render(props: Partial<Parameters<typeof ChatsSection>[0]> = {}) {
6378
await act(async () => {
6479
root.render(
65-
<ChatsSection
66-
chats={CHATS}
67-
isLoading={false}
68-
isCollapsed={false}
69-
pathname={null}
70-
menuOpenHref={null}
71-
onContextMenu={() => {}}
72-
onMoreClick={() => {}}
73-
{...props}
74-
/>
80+
<QueryClientProvider client={queryClient}>
81+
<ChatsSection
82+
chats={CHATS}
83+
isLoading={false}
84+
isCollapsed={false}
85+
pathname={null}
86+
menuOpenHref={null}
87+
onContextMenu={() => {}}
88+
onMoreClick={() => {}}
89+
{...props}
90+
/>
91+
</QueryClientProvider>
7592
)
7693
})
7794
}
@@ -103,6 +120,28 @@ describe('ChatsSection', () => {
103120
await act(async () => button?.click())
104121

105122
expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2')
123+
expect(prefetchQuery).not.toHaveBeenCalled()
124+
})
125+
126+
it.each([false, true])(
127+
'prefetches focused destination history with collapsed=%s',
128+
async (isCollapsed) => {
129+
hoverState.isOpen = isCollapsed
130+
await render({ isCollapsed })
131+
prefetchQuery.mockClear()
132+
const link = document.body.querySelector<HTMLAnchorElement>('a[href="/o/org-1/chat/chat-3"]')!
133+
await act(async () => link.focus())
134+
expect(prefetchQuery).toHaveBeenCalledWith(
135+
expect.objectContaining({ queryKey: ['mothership-chats', 'detail', 'chat-3'] })
136+
)
137+
}
138+
)
139+
140+
it('does not prefetch the active conversation', async () => {
141+
await render({ pathname: '/o/org-1/chat/chat-3' })
142+
const link = container.querySelector<HTMLAnchorElement>('a[href="/o/org-1/chat/chat-3"]')!
143+
await act(async () => link.focus())
144+
expect(prefetchQuery).not.toHaveBeenCalled()
106145
})
107146

108147
it('shows the empty state when there are no chats', async () => {

apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

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

4343
return (
44-
<Link
44+
<ChatNavigationLink
4545
href={chat.href}
46+
chatId={chat.id}
47+
isCurrentRoute={isCurrentRoute}
4648
className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })}
4749
onContextMenu={(e) => onContextMenu(e, chat.href)}
4850
>
@@ -83,7 +85,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick
8385
<MoreHorizontal className='size-[14px] text-[var(--text-icon)]' />
8486
</button>
8587
</div>
86-
</Link>
88+
</ChatNavigationLink>
8789
)
8890
}
8991

@@ -140,13 +142,18 @@ export function ChatsSection({
140142
const isCurrentRoute = pathname === chat.href
141143
return (
142144
<DropdownMenuItem key={chat.id} asChild active={isCurrentRoute}>
143-
<Link href={chat.href} onContextMenu={(e) => onContextMenu(e, chat.href)}>
145+
<ChatNavigationLink
146+
href={chat.href}
147+
chatId={chat.id}
148+
isCurrentRoute={isCurrentRoute}
149+
onContextMenu={(e) => onContextMenu(e, chat.href)}
150+
>
144151
<ConversationListItem
145152
title={chat.name}
146153
isActive={Boolean(chat.isActive)}
147154
isUnread={Boolean(chat.isUnread) && !isCurrentRoute}
148155
/>
149-
</Link>
156+
</ChatNavigationLink>
150157
</DropdownMenuItem>
151158
)
152159
})

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use client'
22

3+
import { useRef } from 'react'
34
import { Button, cn } from '@sim/emcn'
45
import { ArrowUp } from '@sim/emcn/icons'
56
import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder'
7+
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
68

79
const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors'
810
const SEND_BUTTON_ACTIVE =
@@ -32,6 +34,8 @@ export function Composer({
3234
onSubmit,
3335
onStop,
3436
}: ComposerProps) {
37+
const textareaRef = useRef<HTMLTextAreaElement>(null)
38+
useChatInputFocus({ textareaRef })
3539
const canSubmit = value.trim().length > 0
3640
const animatedPlaceholder = useAnimatedPlaceholder(isInitialView)
3741
const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim'
@@ -50,6 +54,7 @@ export function Composer({
5054
)}
5155
>
5256
<textarea
57+
ref={textareaRef}
5358
value={value}
5459
onChange={(event) => onChange(event.target.value)}
5560
onKeyDown={(event) => {

apps/sim/app/o/[organizationId]/home/organization-home.test.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @vitest-environment jsdom */
2-
import { act, type ComponentProps } from 'react'
2+
import { act, type ComponentProps, type ReactNode } from 'react'
33
import { createRoot, type Root } from 'react-dom/client'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
55

@@ -96,6 +96,52 @@ describe('organization home', () => {
9696
expect(container.textContent).not.toContain('Get started')
9797
expect(mocks.consume).not.toHaveBeenCalled()
9898
})
99+
it('keeps the composer available when messages exist while history is pending', async () => {
100+
mocks.chat.mockReturnValue({
101+
messages: [{ id: 'message-a', role: 'user', content: 'A question' }],
102+
isChatHistoryPending: true,
103+
sendMessage: mocks.send,
104+
})
105+
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
106+
expect(mocks.renderer).toHaveBeenCalledWith(
107+
expect.objectContaining({ isLoading: false }),
108+
undefined
109+
)
110+
})
111+
it('isolates conversation state when switching cached chats', async () => {
112+
mocks.chat.mockReturnValue({
113+
messages: [],
114+
isChatHistoryPending: false,
115+
sendMessage: mocks.send,
116+
})
117+
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
118+
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
119+
await act(async () => composerProps().onChange('A draft for chat A'))
120+
await act(async () => root.render(<OrganizationHome chatId='chat-a' />))
121+
expect(composerProps().value).toBe('A draft for chat A')
122+
await act(async () => root.render(<OrganizationHome chatId='chat-b' />))
123+
expect(composerProps().value).toBe('')
124+
expect(mocks.chat).toHaveBeenLastCalledWith({ organizationId: 'organization-a' }, 'chat-b')
125+
expect(mocks.send).not.toHaveBeenCalled()
126+
})
127+
it('preserves the conversation when the first send adopts a chat ID', async () => {
128+
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
129+
await act(async () => root.render(<OrganizationHome />))
130+
await act(async () => composerProps().onChange('A follow-up draft'))
131+
mocks.chat.mockReturnValue({
132+
messages: [{ id: 'message-a', role: 'user', content: 'First question' }],
133+
resolvedChatId: 'chat-a',
134+
isChatHistoryPending: true,
135+
isSending: true,
136+
sendMessage: mocks.send,
137+
})
138+
await act(async () => root.render(<OrganizationHome />))
139+
expect(composerProps().value).toBe('A follow-up draft')
140+
expect(mocks.renderer).toHaveBeenCalledWith(
141+
expect.objectContaining({ chatId: 'chat-a', isLoading: false, isSending: true }),
142+
undefined
143+
)
144+
})
99145
it.each([
100146
{ isAdmin: true, integrationHref: '/o/organization-a/settings/integrations' },
101147
{ isAdmin: false, integrationHref: '/o/organization-a/integrations' },

apps/sim/app/o/[organizationId]/home/organization-home.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ interface OrganizationHomeProps {
1717

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

2525
function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
@@ -82,7 +82,7 @@ function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
8282
messages={chat.messages}
8383
isSending={chat.isSending}
8484
isReconnecting={chat.isReconnecting}
85-
isLoading={Boolean(chatId) && chat.isChatHistoryPending}
85+
isLoading={Boolean(chatId) && !chat.messages.length && chat.isChatHistoryPending}
8686
onSubmit={send}
8787
onStopGeneration={() => {
8888
void chat.stopGeneration()

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

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import type {
4141
import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
4242
import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
4343
import { mentionifyIntegrations } from '@/blocks/integration-matcher'
44+
import { useChatInputFocus } from '@/hooks/use-chat-input-focus'
4445
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4546
import { type SpeechToTextError, useSpeechToText } from '@/hooks/use-speech-to-text'
4647
import { type DraftPayload, useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
@@ -50,16 +51,6 @@ export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/ty
5051

5152
const logger = createLogger('UserInput')
5253

53-
/**
54-
* Whether the element is somewhere the user could be typing. Focusing the composer on mount
55-
* must not steal focus from another field, but may take it from a link or button — opening a
56-
* chat leaves the sidebar link focused, and the composer should win.
57-
*/
58-
function isTextEntry(element: HTMLElement): boolean {
59-
const tag = element.tagName
60-
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable
61-
}
62-
6354
interface UserInputProps {
6455
defaultValue?: string
6556
draftScopeKey?: string
@@ -168,6 +159,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
168159
const editorRef = useRef(editor)
169160
editorRef.current = editor
170161
const textareaRef = editor.textareaRef
162+
useChatInputFocus({ textareaRef })
171163

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

552-
useEffect(() => {
553-
const raf = window.requestAnimationFrame(() => {
554-
if (!document.hasFocus()) return
555-
const active = document.activeElement
556-
if (active instanceof HTMLElement && isTextEntry(active)) return
557-
textareaRef.current?.focus()
558-
})
559-
return () => window.cancelAnimationFrame(raf)
560-
}, [textareaRef])
561-
562544
/**
563545
* Menu rows are excluded alongside buttons: the mode switcher's items are
564546
* portaled, so their clicks still bubble here through the React tree.

0 commit comments

Comments
 (0)