Skip to content

Commit 2287484

Browse files
committed
feat(chat): unify Home modes and search resource tabs
1 parent ac5dc14 commit 2287484

62 files changed

Lines changed: 2309 additions & 495 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
77
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
88
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
99
import OrganizationSearchLoading from '@/app/o/[organizationId]/search/loading'
10-
import { OrganizationSearch } from '@/app/o/[organizationId]/search/search'
1110

1211
export const metadata: Metadata = { title: 'Chat' }
1312

@@ -31,7 +30,11 @@ export default async function OrganizationChatPage({
3130
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
3231
return (
3332
<Suspense fallback={<OrganizationSearchLoading />}>
34-
<OrganizationSearch userName={session.user.name ?? undefined} chatId={chatId} />
33+
<OrganizationHome
34+
requestMode='assistant'
35+
userName={session.user.name ?? undefined}
36+
chatId={chatId}
37+
/>
3538
</Suspense>
3639
)
3740
}

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

Lines changed: 235 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** @vitest-environment jsdom */
2-
import { act, useState } from 'react'
2+
import { act, type ComponentProps, useState } from 'react'
33
import { toast } from '@sim/emcn'
44
import { createRoot, type Root } from 'react-dom/client'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -10,9 +10,45 @@ const mocks = vi.hoisted(() => ({
1010
toggleListening: vi.fn(),
1111
resetTranscript: vi.fn(),
1212
submit: vi.fn(),
13+
contexts: vi.fn(),
1314
upload: vi.fn(),
1415
}))
1516

17+
vi.mock('@/hooks/queries/workspace', () => ({
18+
useWorkspacesQuery: () => ({
19+
data: [
20+
{
21+
id: 'workspace-a',
22+
name: 'Team',
23+
organizationId: 'organization-a',
24+
workspaceMode: 'grandfathered_shared',
25+
},
26+
{
27+
id: 'workspace-other',
28+
name: 'Other org',
29+
organizationId: 'organization-b',
30+
workspaceMode: 'organization',
31+
},
32+
],
33+
}),
34+
}))
35+
vi.mock('@/hooks/queries/skills', () => ({
36+
useSkills: (workspaceId: string) => ({
37+
data:
38+
workspaceId === 'workspace-a'
39+
? [{ id: 'skill-a', name: 'review', description: 'Review a draft' }]
40+
: [],
41+
}),
42+
}))
43+
vi.mock('@/hooks/queries/mcp', () => ({ useMcpToolServers: () => ({ data: [] }) }))
44+
vi.mock('@/blocks/integration-matcher', () => ({
45+
getIntegrationMatcher: () => ({ regex: null, byName: new Map() }),
46+
}))
47+
vi.mock(
48+
'@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown',
49+
() => ({ PlusMenuDropdown: () => null })
50+
)
51+
1652
vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
1753
vi.mock('@/lib/uploads/client/session-upload', () => ({ uploadInternalFileSession: mocks.upload }))
1854
vi.mock('@/hooks/use-animated-placeholder', () => ({ useAnimatedPlaceholder: () => 'Ask Sim to' }))
@@ -30,6 +66,22 @@ let container: HTMLDivElement
3066
beforeEach(() => {
3167
vi.clearAllMocks()
3268
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
69+
HTMLElement.prototype.scrollIntoView = vi.fn()
70+
vi.stubGlobal(
71+
'DataTransfer',
72+
class {
73+
files: File[] = []
74+
items = { add: (file: File) => this.files.push(file) }
75+
}
76+
)
77+
vi.stubGlobal(
78+
'ResizeObserver',
79+
class {
80+
observe() {}
81+
disconnect() {}
82+
unobserve() {}
83+
}
84+
)
3385
vi.stubGlobal(
3486
'URL',
3587
class extends URL {
@@ -71,7 +123,11 @@ afterEach(async () => {
71123
async function render(
72124
isInitialView: boolean,
73125
initialValue = 'Summarize',
74-
requestMode: 'agent' | 'assistant' = 'assistant'
126+
requestMode: 'agent' | 'assistant' = 'assistant',
127+
controls: Pick<
128+
ComponentProps<typeof Composer>,
129+
'isSending' | 'showModeSelector' | 'onModeChange' | 'modeChangeDisabled' | 'restoredContexts'
130+
> = { isSending: false }
75131
) {
76132
function Harness() {
77133
const [value, setValue] = useState(initialValue)
@@ -83,14 +139,19 @@ async function render(
83139
return (
84140
<Composer
85141
requestMode={requestMode}
142+
showModeSelector={controls.showModeSelector}
143+
onModeChange={controls.onModeChange}
144+
modeChangeDisabled={controls.modeChangeDisabled}
145+
restoredContexts={controls.restoredContexts}
86146
value={value}
87147
files={files}
88148
onChange={setValue}
89149
isInitialView={isInitialView}
90-
isSending={false}
150+
isSending={controls.isSending}
91151
onStop={vi.fn()}
92-
onSubmit={() => {
93-
mocks.submit(value, files.attachedFiles)
152+
onSubmit={(text, contexts) => {
153+
mocks.submit(text, files.attachedFiles)
154+
mocks.contexts(contexts)
94155
setValue('')
95156
files.clearAttachedFiles()
96157
}}
@@ -106,6 +167,9 @@ describe('organization voice composer', () => {
106167
async (isInitialView) => {
107168
await render(isInitialView)
108169
const mic = container.querySelector<HTMLButtonElement>('button[aria-label="Voice input"]')!
170+
expect(mic.previousElementSibling?.getAttribute('aria-label')).toBe(
171+
'Model and reasoning effort'
172+
)
109173
expect(mic.nextElementSibling?.getAttribute('aria-label')).toBe('Send')
110174
await act(async () => mic.click())
111175
expect(mocks.toggleListening).toHaveBeenCalledOnce()
@@ -144,7 +208,14 @@ function fileList(files: File[]): FileList {
144208

145209
async function paste(files: File[]) {
146210
const event = new Event('paste', { bubbles: true, cancelable: true })
147-
Object.defineProperty(event, 'clipboardData', { value: { files: fileList(files) } })
211+
Object.defineProperty(event, 'clipboardData', {
212+
value: {
213+
files: fileList(files),
214+
items: files.map((file) => ({ kind: 'file', getAsFile: () => file })),
215+
getData: () => '',
216+
types: [],
217+
},
218+
})
148219
await act(async () => container.querySelector('textarea')!.dispatchEvent(event))
149220
return event
150221
}
@@ -264,3 +335,161 @@ it('uploads an agent document with explicit mode while Assistant remains image-o
264335
expect(container.querySelector('input[type="file"]')?.getAttribute('accept')).toContain('.txt')
265336
expect(container.querySelector('button[aria-label="Model and reasoning effort"]')).not.toBeNull()
266337
})
338+
339+
it('discovers an organization-owned legacy workspace and sends its scoped skill context', async () => {
340+
await render(true, '', 'agent')
341+
const slash = container.querySelector<HTMLButtonElement>('button[aria-label="Skills"]')!
342+
expect(slash.previousElementSibling?.getAttribute('aria-label')).toBe('Attach file')
343+
expect(slash.previousElementSibling?.previousElementSibling?.getAttribute('aria-label')).toBe(
344+
'Add resources'
345+
)
346+
await act(async () =>
347+
slash.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
348+
)
349+
expect(document.body.textContent).not.toContain('Other org')
350+
const workspace = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].find(
351+
(item) => item.textContent === 'Team'
352+
)!
353+
await act(async () => workspace.click())
354+
const skill = [...document.querySelectorAll<HTMLElement>('button')].find(
355+
(item) => item.textContent === 'review'
356+
)!
357+
expect(skill).toBeDefined()
358+
await act(async () => skill.click())
359+
await act(async () =>
360+
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
361+
)
362+
expect(mocks.submit.mock.calls.at(-1)?.[0]).toBe('/review ')
363+
expect(mocks.contexts).toHaveBeenLastCalledWith([
364+
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
365+
])
366+
})
367+
368+
it('keeps restored queued skills scoped when replacing a draft', async () => {
369+
let restore: () => void = () => {}
370+
function Harness() {
371+
const [value, setValue] = useState('Original draft')
372+
const [contexts, setContexts] = useState<ComponentProps<typeof Composer>['restoredContexts']>()
373+
restore = () => {
374+
setValue('/review fix this')
375+
setContexts([
376+
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
377+
])
378+
}
379+
const files = useFileAttachments({
380+
userId: 'user-a',
381+
organizationId: 'organization-a',
382+
requestMode: 'agent',
383+
})
384+
return (
385+
<Composer
386+
requestMode='agent'
387+
value={value}
388+
onChange={setValue}
389+
restoredContexts={contexts}
390+
files={files}
391+
isInitialView={false}
392+
isSending={false}
393+
onStop={() => {}}
394+
onSubmit={mocks.submit}
395+
/>
396+
)
397+
}
398+
await act(async () => root.render(<Harness />))
399+
await act(async () => restore())
400+
expect(container.querySelector('textarea')!.value).toBe('\u2003review fix this')
401+
await act(async () =>
402+
container.querySelector<HTMLButtonElement>('button[aria-label="Send"]')!.click()
403+
)
404+
expect(mocks.submit).toHaveBeenLastCalledWith('/review fix this', [
405+
{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' },
406+
])
407+
})
408+
409+
it('does not expose resource or skill controls in Search', async () => {
410+
await render(true)
411+
expect(container.querySelector('[aria-label="Skills"]')).toBeNull()
412+
expect(container.querySelector('[aria-label="Add resources"]')).toBeNull()
413+
expect(container.querySelector('[aria-label="Attach images"]')).not.toBeNull()
414+
})
415+
416+
it('lists organization-owned legacy workspaces in the resource picker', async () => {
417+
await render(true, '', 'agent')
418+
const plus = container.querySelector<HTMLButtonElement>('button[aria-label="Add resources"]')!
419+
await act(async () =>
420+
plus.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
421+
)
422+
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(
423+
(item) => item.textContent
424+
)
425+
expect(items).toContain('Team')
426+
expect(items).not.toContain('Other org')
427+
expect(items).not.toContain('No accessible workspaces')
428+
})
429+
430+
it('offers a text-only controlled mode picker after the staging input controls', async () => {
431+
const onModeChange = vi.fn()
432+
await render(true, 'Preserved draft', 'agent', {
433+
isSending: false,
434+
showModeSelector: true,
435+
onModeChange,
436+
})
437+
const mode = container.querySelector<HTMLButtonElement>('[aria-label="Conversation mode"]')!
438+
expect(mode.previousElementSibling?.getAttribute('aria-label')).toBe('Skills')
439+
expect(mode.querySelector('svg')).toBeNull()
440+
await act(async () =>
441+
mode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
442+
)
443+
const search = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find(
444+
(item) => item.textContent === 'Search'
445+
)!
446+
expect(search.querySelector('svg')).toBeNull()
447+
await act(async () => search.click())
448+
expect(onModeChange).toHaveBeenCalledExactlyOnceWith('assistant')
449+
expect(container.querySelector('textarea')!.value).toBe('Preserved draft')
450+
})
451+
452+
it('disables the mode picker when the parent has queued messages', async () => {
453+
await render(true, '', 'agent', {
454+
isSending: false,
455+
showModeSelector: true,
456+
onModeChange: vi.fn(),
457+
modeChangeDisabled: true,
458+
})
459+
expect(
460+
container.querySelector<HTMLButtonElement>('[aria-label="Conversation mode"]')!.disabled
461+
).toBe(true)
462+
})
463+
464+
it.each(['skill', 'file'] as const)(
465+
'keeps a Build draft intact when %s context cannot move to Search',
466+
async (kind) => {
467+
const onModeChange = vi.fn()
468+
const info = vi.spyOn(toast, 'info').mockReturnValue('notice')
469+
await render(true, kind === 'skill' ? '/review Draft' : 'Document draft', 'agent', {
470+
isSending: false,
471+
showModeSelector: true,
472+
onModeChange,
473+
restoredContexts:
474+
kind === 'skill'
475+
? [{ kind: 'skill', skillId: 'skill-a', label: 'review', workspaceId: 'workspace-a' }]
476+
: undefined,
477+
})
478+
if (kind === 'file')
479+
await paste([new File(['document'], 'note.pdf', { type: 'application/pdf' })])
480+
const before = container.querySelector('textarea')!.value
481+
const mode = container.querySelector<HTMLButtonElement>('[aria-label="Conversation mode"]')!
482+
await act(async () =>
483+
mode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
484+
)
485+
const search = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find(
486+
(item) => item.textContent === 'Search'
487+
)!
488+
await act(async () => search.click())
489+
expect(onModeChange).not.toHaveBeenCalled()
490+
expect(info).toHaveBeenCalledWith(
491+
'Remove resource and skill mentions and non-image attachments before switching to Search.'
492+
)
493+
expect(container.querySelector('textarea')!.value).toBe(before)
494+
}
495+
)

0 commit comments

Comments
 (0)