Skip to content

Commit 6950d03

Browse files
committed
Merge the Home lane
2 parents 35c9050 + b10ed6c commit 6950d03

72 files changed

Lines changed: 5113 additions & 225 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.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getOrganizationHomeCalendarContract } from '@/lib/api/contracts/organization-home'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import {
8+
organizationHomeOperations,
9+
readOrganizationHomeCalendar,
10+
} from '@/lib/organizations/application/home-widgets'
11+
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getOrganizationHomeCalendarContract,
15+
auth: internalSessionAuth,
16+
operation: organizationHomeOperations.readCalendar,
17+
rateLimit: internalRateLimits.user({ bucketName: 'organization-home-calendar' }),
18+
errorPolicy: createCredentialGroupInternalErrorPolicy(
19+
'Failed to load calendar',
20+
'Organization not found'
21+
),
22+
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
23+
useCase: readOrganizationHomeCalendar,
24+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
25+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getOrganizationHomeEmailContract } from '@/lib/api/contracts/organization-home'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import {
8+
organizationHomeOperations,
9+
readOrganizationHomeEmail,
10+
} from '@/lib/organizations/application/home-widgets'
11+
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getOrganizationHomeEmailContract,
15+
auth: internalSessionAuth,
16+
operation: organizationHomeOperations.readEmail,
17+
rateLimit: internalRateLimits.user({ bucketName: 'organization-home-email' }),
18+
errorPolicy: createCredentialGroupInternalErrorPolicy(
19+
'Failed to load email',
20+
'Organization not found'
21+
),
22+
mapInput: ({ params }) => ({ organizationId: params.id }),
23+
useCase: readOrganizationHomeEmail,
24+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
25+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { removeSearchHistorySourceContract } from '@/lib/api/contracts/knowledge/search-history'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import {
9+
removeSearchHistorySource,
10+
searchHistoryOperations,
11+
} from '@/lib/knowledge/application/search-history'
12+
13+
export const DELETE = defineInternalJsonRoute({
14+
contract: removeSearchHistorySourceContract,
15+
auth: internalSessionAuth,
16+
operation: searchHistoryOperations.removeSource,
17+
rateLimit: internalRateLimits.user({ bucketName: 'search-history-write' }),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
parseOptions: { maxBodyBytes: 24 * 1024 },
20+
mapInput: ({ params, body }) => ({ organizationId: params.id, url: body.url }),
21+
useCase: removeSearchHistorySource,
22+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
23+
})

‎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('hands its composer and the Assistant shortcuts to the surface’s landing, in place of the default heading', 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+
})

0 commit comments

Comments
 (0)