Skip to content

Commit 4bc42a1

Browse files
committed
feat(org): put the dashboard Home in the organization view
Home keeps the real conversation and composer: OrganizationConversation gains a landing slot and a draft id, both defaulting to today's output, so Search and the extension render as before. Members who cannot build now get Home with the Assistant instead of a redirect to Search, and an agent chat still sends them to Search.
1 parent 560a49c commit 4bc42a1

37 files changed

Lines changed: 3244 additions & 28 deletions

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/pa
77
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
88
import OrganizationChatLoading from '@/app/o/[organizationId]/chat/[chatId]/loading'
99
import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
10-
import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect'
10+
import { getOrganizationBuildRedirect } from '@/app/o/[organizationId]/home/home-redirect'
1111

1212
export const metadata: Metadata = { title: 'Chat' }
1313

@@ -22,7 +22,7 @@ export default async function OrganizationChatPage({
2222
const context = await getOrganizationSurfaceContext(organizationId, session.user.id)
2323
if (!context) notFound()
2424
if (!context.mothershipAvailable || (!context.canBuild && !context.searchAccess.memberScoped)) {
25-
const unavailableRedirect = getOrganizationHomeRedirect(context, organizationId)
25+
const unavailableRedirect = getOrganizationBuildRedirect(context, organizationId)
2626
if (unavailableRedirect) redirect(unavailableRedirect)
2727
}
2828
const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, {
@@ -33,8 +33,8 @@ export default async function OrganizationChatPage({
3333
if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH)
3434
redirect(organizationRoutes(organizationId).searchChat(chatId))
3535
}
36-
const homeRedirect = getOrganizationHomeRedirect(context, organizationId)
37-
if (homeRedirect) redirect(homeRedirect)
36+
const buildRedirect = getOrganizationBuildRedirect(context, organizationId)
37+
if (buildRedirect) redirect(buildRedirect)
3838

3939
return (
4040
<Suspense fallback={<OrganizationChatLoading />}>
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* A surface's own landing must not change the conversation it fronts. Failure modes: Home's
5+
* Assistant sharing Search's unsent question, a new chat losing its Search address, the landing
6+
* outliving the first turn, a restored chat entry flashing the dashboard's skeleton, and a caller
7+
* without a landing (the extension, Search) seeing any of it.
8+
*/
9+
import { act, type ReactNode } from 'react'
10+
import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock'
11+
import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock'
12+
import { kbConnectorsQueriesMock } from '@sim/testing/mocks/kb-connectors-queries.mock'
13+
import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock'
14+
import {
15+
organizationProviderMock,
16+
organizationProviderMockFns,
17+
} from '@sim/testing/mocks/organization-provider.mock'
18+
import {
19+
createMutationResultMock,
20+
createQueryResultMock,
21+
reactQueryMock,
22+
reactQueryMockFns,
23+
} from '@sim/testing/mocks/react-query.mock'
24+
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
25+
import { createRoot, type Root } from 'react-dom/client'
26+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
27+
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
28+
29+
const mocks = vi.hoisted(() => ({
30+
chat: vi.fn(),
31+
composer: vi.fn(),
32+
send: vi.fn(),
33+
}))
34+
vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () => ({
35+
useFeatureFlag: () => false,
36+
}))
37+
vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock)
38+
vi.mock('next/navigation', () => nextNavigationMock)
39+
vi.mock('@tanstack/react-query', () => reactQueryMock)
40+
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({
41+
useResourcePanelController: () => ({
42+
onResourceEvent: undefined,
43+
activeResourceState: [null, vi.fn()],
44+
activeResourceParam: null,
45+
}),
46+
useChatResourcePanel: () => ({
47+
isResourceCollapsed: true,
48+
addResourceFromUser: vi.fn(),
49+
prepareResourceViewForAgentTurn: vi.fn(),
50+
}),
51+
}))
52+
vi.mock('@/app/workspace/[workspaceId]/home/components/chat-resource-panel', () => ({
53+
ChatResourcePanel: ({ children }: { children: ReactNode }) => children,
54+
}))
55+
vi.mock('@/lib/auth/client', () => authClientMock)
56+
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock)
57+
vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({
58+
getMothershipUseChatOptions: (options: object) => options,
59+
useChat: mocks.chat,
60+
}))
61+
vi.mock('@/hooks/queries/mothership-chats', () => ({
62+
useMarkMothershipChatRead: () => ({ mutate: vi.fn() }),
63+
}))
64+
vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer }))
65+
vi.mock('@/app/o/[organizationId]/home/components/get-started', () => ({
66+
GetStarted: () => <div>Legacy get started</div>,
67+
}))
68+
vi.mock('@/app/workspace/[workspaceId]/home/components/suggested-actions', () => ({
69+
SuggestedActions: () => <div>Suggested actions</div>,
70+
}))
71+
vi.mock('@/hooks/queries/kb/connectors', () => kbConnectorsQueriesMock)
72+
vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({
73+
MothershipChat: () => <div>Conversation view</div>,
74+
}))
75+
76+
import {
77+
OrganizationConversation,
78+
type OrganizationLandingSlot,
79+
} from '@/app/o/[organizationId]/components/organization-conversation'
80+
81+
const SEARCH_DRAFT = 'reader:organization:organization-a:search-assistant'
82+
const HOME_DRAFT = 'reader:organization:organization-a:home-assistant'
83+
84+
const landing = {
85+
fallback: <div>Dashboard skeleton</div>,
86+
render: ({ requestMode, composer, shortcuts }: OrganizationLandingSlot) => (
87+
<div>
88+
<div>Dashboard for {requestMode}</div>
89+
{composer}
90+
<div data-testid='shortcuts'>{shortcuts}</div>
91+
</div>
92+
),
93+
}
94+
95+
let root: Root
96+
let container: HTMLDivElement
97+
98+
async function render(element: ReactNode) {
99+
await act(async () => {
100+
root.render(<NuqsTestingAdapter hasMemory>{element}</NuqsTestingAdapter>)
101+
})
102+
}
103+
104+
function composerProps() {
105+
const call = mocks.composer.mock.lastCall
106+
if (!call) throw new Error('Composer has not rendered')
107+
return call[0]
108+
}
109+
110+
beforeEach(() => {
111+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
112+
reactQueryMockFns.mockUseQuery.mockReturnValue(createQueryResultMock())
113+
reactQueryMockFns.mockUseMutation.mockReturnValue(createMutationResultMock())
114+
useMothershipDraftsStore.setState({ drafts: {} })
115+
nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization-a/home')
116+
authClientMockFns.mockUseSession.mockReturnValue({ data: { user: { id: 'reader' } } })
117+
organizationProviderMockFns.mockUseOrganizationContext.mockReturnValue({
118+
organization: { id: 'organization-a', name: 'Acme' },
119+
searchAccess: { memberScoped: true },
120+
mothershipAvailable: true,
121+
canBuild: false,
122+
viewer: { isAdmin: false, canUseSearchMcp: true },
123+
})
124+
mocks.chat.mockReturnValue({ messages: [], resources: [], sendMessage: mocks.send })
125+
mocks.composer.mockReturnValue(<div>Question composer</div>)
126+
container = document.createElement('div')
127+
document.body.appendChild(container)
128+
root = createRoot(container)
129+
})
130+
131+
afterEach(async () => {
132+
await act(async () => root.unmount())
133+
container.remove()
134+
})
135+
136+
describe('an Assistant conversation on a surface with its own landing', () => {
137+
it('arranges the real composer and the mode’s shortcuts in the surface’s landing', async () => {
138+
await render(
139+
<OrganizationConversation
140+
requestMode='assistant'
141+
draftId='home-assistant'
142+
landing={landing}
143+
/>
144+
)
145+
expect(container.textContent).toContain('Dashboard for assistant')
146+
expect(container.textContent).toContain('Question composer')
147+
expect(container.querySelector('[data-testid="shortcuts"]')?.textContent).toBe(
148+
'Legacy get started'
149+
)
150+
expect(container.querySelector('h1')).toBeNull()
151+
})
152+
153+
it('keeps its unsent question apart from Search’s Assistant, in both directions', async () => {
154+
useMothershipDraftsStore.getState().setDraft(SEARCH_DRAFT, { text: 'Asked from Search' })
155+
await render(
156+
<OrganizationConversation
157+
requestMode='assistant'
158+
draftId='home-assistant'
159+
landing={landing}
160+
/>
161+
)
162+
expect(composerProps().value).toBe('')
163+
await act(async () => composerProps().onChange('Asked from Home'))
164+
const { drafts } = useMothershipDraftsStore.getState()
165+
expect(drafts[HOME_DRAFT]?.text).toBe('Asked from Home')
166+
expect(drafts[SEARCH_DRAFT]?.text).toBe('Asked from Search')
167+
})
168+
169+
it('addresses a new chat under Search, in the Assistant view', async () => {
170+
await render(
171+
<OrganizationConversation
172+
requestMode='assistant'
173+
draftId='home-assistant'
174+
landing={landing}
175+
/>
176+
)
177+
const [, chatId, options] = mocks.chat.mock.lastCall ?? []
178+
expect(chatId).toBeUndefined()
179+
expect(options.requestMode).toBe('assistant')
180+
expect(options.getChatUrl('chat-1')).toBe('/o/organization-a/search/chat/chat-1?mode=assistant')
181+
})
182+
183+
it('gives way to today’s conversation view at the first turn', async () => {
184+
mocks.chat.mockReturnValue({
185+
messages: [{ id: 'turn' }],
186+
resources: [],
187+
sendMessage: mocks.send,
188+
})
189+
await render(
190+
<OrganizationConversation
191+
requestMode='assistant'
192+
draftId='home-assistant'
193+
landing={landing}
194+
/>
195+
)
196+
expect(container.textContent).toContain('Conversation view')
197+
expect(container.textContent).not.toContain('Dashboard for')
198+
})
199+
200+
it('holds the surface’s fallback until the session arrives', async () => {
201+
authClientMockFns.mockUseSession.mockReturnValue({ data: null })
202+
await render(<OrganizationConversation requestMode='assistant' landing={landing} />)
203+
expect(container.textContent).toBe('Dashboard skeleton')
204+
})
205+
206+
it('shows no dashboard while a restored chat entry hands itself back to the router', async () => {
207+
nextNavigationMockFns.mockUsePathname.mockReturnValue('/o/organization-a/search/chat/chat-1')
208+
await render(<OrganizationConversation requestMode='assistant' landing={landing} />)
209+
expect(container.textContent).toBe('')
210+
})
211+
})
212+
213+
describe('a conversation without a landing', () => {
214+
it('keeps today’s landing and Search’s draft key', async () => {
215+
useMothershipDraftsStore.getState().setDraft(SEARCH_DRAFT, { text: 'Asked from Search' })
216+
await render(<OrganizationConversation requestMode='assistant' />)
217+
expect(container.querySelector('h1')?.textContent).toBe('Search Acme')
218+
expect(container.textContent).toContain('Legacy get started')
219+
expect(composerProps().value).toBe('Asked from Search')
220+
})
221+
})

‎apps/sim/app/o/[organizationId]/components/organization-conversation.tsx‎

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export interface OrganizationSummaryRequest {
7878
assistantSearch: WorkspaceSearchFilters
7979
}
8080

81+
/** What a surface's own landing arranges in place of the default one. */
82+
export interface OrganizationLandingSlot {
83+
requestMode: ChatRequestMode
84+
composer: ReactNode
85+
shortcuts: ReactNode
86+
}
87+
8188
const serializeSearchConversation = createSerializer({
8289
...organizationSearchParsers,
8390
...searchFilterParsers,
@@ -92,6 +99,13 @@ interface OrganizationConversationProps {
9299
chatId?: string
93100
requestMode: ChatRequestMode
94101
header?: ReactNode
102+
/** Names this surface's new-chat draft, for a surface that must not share another's. */
103+
draftId?: string
104+
/** Replaces the landing, and the blank frame before it, until the first turn takes over. */
105+
landing?: {
106+
fallback: ReactNode
107+
render: (slot: OrganizationLandingSlot) => ReactNode
108+
}
95109
panelOptions?: {
96110
header: ReactNode
97111
attachments?: ReactNode
@@ -117,8 +131,9 @@ export function OrganizationConversation(props: OrganizationConversationProps) {
117131
(props.requestMode === 'assistant' ? !searchAccess.memberScoped : !canBuild)
118132
)
119133
return null
120-
if (isRestoredChatEntry || !isClient || !session?.user?.id)
121-
return <HomeFallback header={props.header} />
134+
if (isRestoredChatEntry) return <HomeFallback header={props.header} />
135+
if (!isClient || !session?.user?.id)
136+
return props.landing?.fallback ?? <HomeFallback header={props.header} />
122137
return (
123138
<OrganizationConversationContent
124139
key={`${session.user.id}:${organization.id}:${props.chatId ?? 'new'}:${props.panelOptions ? 'panel' : props.requestMode}`}
@@ -135,6 +150,8 @@ function OrganizationConversationContent({
135150
active = true,
136151
summaryRequest,
137152
onSummaryConsumed,
153+
draftId,
154+
landing,
138155
panelOptions,
139156
}: OrganizationConversationProps) {
140157
const { organization, canBuild } = useOrganizationContext()
@@ -171,7 +188,8 @@ function OrganizationConversationContent({
171188
? { projectsDesktopTabs: false, onChatIdResolved: panelOptions.onChatIdResolved }
172189
: {}),
173190
})
174-
const newDraftId = !compact && destinationMode === 'assistant' ? 'search-assistant' : 'home'
191+
const newDraftId =
192+
draftId ?? (!compact && destinationMode === 'assistant' ? 'search-assistant' : 'home')
175193
const draftPrefix = panelOptions ? `${panelOptions.draftNamespace}:` : ''
176194
const initialDraftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chatId ?? newDraftId}`
177195
const draftKey = `${draftPrefix}${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? newDraftId}`
@@ -492,6 +510,16 @@ function OrganizationConversationContent({
492510
/>
493511
)
494512

513+
const shortcuts =
514+
requestMode !== 'assistant' ? (
515+
<SuggestedActions
516+
organizationId={organization.id}
517+
onSelectPrompt={(prompt) => setDraft(mentionifyIntegrations(prompt))}
518+
/>
519+
) : (
520+
<GetStarted />
521+
)
522+
495523
const content = (
496524
<div
497525
className={cn(
@@ -549,6 +577,8 @@ function OrganizationConversationContent({
549577
</div>
550578
<ComposerScrollArea className='px-3 pb-3'>{composer}</ComposerScrollArea>
551579
</>
580+
) : landing ? (
581+
landing.render({ requestMode, composer, shortcuts })
552582
) : (
553583
<OrganizationLanding
554584
heading={
@@ -560,16 +590,7 @@ function OrganizationConversationContent({
560590
}
561591
>
562592
{composer}
563-
<div className='relative md:absolute md:inset-x-0 md:top-full'>
564-
{requestMode !== 'assistant' ? (
565-
<SuggestedActions
566-
organizationId={organization.id}
567-
onSelectPrompt={(prompt) => setDraft(mentionifyIntegrations(prompt))}
568-
/>
569-
) : (
570-
<GetStarted />
571-
)}
572-
</div>
593+
<div className='relative md:absolute md:inset-x-0 md:top-full'>{shortcuts}</div>
573594
</OrganizationLanding>
574595
)}
575596
</div>
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
22
import type { OrganizationSurfaceContext } from '@/lib/organizations/surface'
33

4-
export function getOrganizationHomeRedirect(
5-
context: Pick<OrganizationSurfaceContext, 'mothershipAvailable' | 'canBuild' | 'searchAccess'>,
4+
type OrganizationRedirectContext = Pick<
5+
OrganizationSurfaceContext,
6+
'mothershipAvailable' | 'canBuild' | 'searchAccess'
7+
>
8+
9+
/** Where an agent conversation sends a viewer who cannot build. */
10+
export function getOrganizationBuildRedirect(
11+
context: OrganizationRedirectContext,
612
organizationId: string
713
): string | null {
814
if (context.mothershipAvailable && context.canBuild) return null
915
return context.searchAccess.memberScoped
1016
? organizationRoutes(organizationId).search
1117
: WORKSPACE_SETTINGS_PATH
1218
}
19+
20+
/** Home serves whoever can hold a conversation here: an agent to build, or an Assistant to search. */
21+
export function getOrganizationHomeRedirect(
22+
context: OrganizationRedirectContext,
23+
organizationId: string
24+
): string | null {
25+
if (context.mothershipAvailable && context.searchAccess.memberScoped) return null
26+
return getOrganizationBuildRedirect(context, organizationId)
27+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { HomeDashboardSkeleton } from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton'
2+
3+
/** A route boundary cannot know the viewer's mode, so it holds the Assistant's, the common one. */
4+
export default function OrganizationHomeLoading() {
5+
return <HomeDashboardSkeleton requestMode='assistant' />
6+
}

0 commit comments

Comments
 (0)