Skip to content

Commit 53f0c01

Browse files
committed
fix(mothership): refresh client settings after scoped mutations
1 parent d0d79f5 commit 53f0c01

23 files changed

Lines changed: 493 additions & 88 deletions

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
77

88
const mocks = vi.hoisted(() => ({
99
invalidateResourceQueries: vi.fn(),
10+
refreshGeneralSettings: vi.fn(),
1011
removeWorkflowFromActiveCache: vi.fn(),
1112
notifyWorkflowExternalUpdate: vi.fn(),
1213
}))
14+
vi.mock('@/hooks/queries/general-settings', () => ({
15+
refreshGeneralSettings: mocks.refreshGeneralSettings,
16+
}))
1317
vi.mock('@/lib/workflows/external-update', () => ({
1418
notifyWorkflowExternalUpdate: mocks.notifyWorkflowExternalUpdate,
1519
}))
@@ -587,3 +591,44 @@ it('seeds only fresh matching search effects and never seeds replayed or foreign
587591
handleResourceEvent({ deps: { ...deps, organizationId: 'other' } } as StreamLoopContext, event)
588592
expect(deps.queryClient.setQueryData).not.toHaveBeenCalled()
589593
})
594+
595+
it.each([undefined, 'ws-1'])(
596+
'refreshes account preferences in either chat scope (%s) without panel effects',
597+
(workspaceId) => {
598+
const refreshRoute = vi.fn()
599+
const deps = makeStreamLoopDeps({
600+
workspaceId,
601+
organizationId: workspaceId ? undefined : 'org',
602+
refreshRoute,
603+
})
604+
handleResourceEvent({ deps } as StreamLoopContext, {
605+
...removeEvent('file', 'unused'),
606+
payload: {
607+
op: 'refresh',
608+
replay: true,
609+
resource: { type: 'settings', scope: 'account', id: 'preferences' },
610+
},
611+
})
612+
expect(mocks.refreshGeneralSettings).toHaveBeenCalledWith(deps.queryClient)
613+
expect(deps.addResource).not.toHaveBeenCalled()
614+
expect(deps.setResources).not.toHaveBeenCalled()
615+
expect(refreshRoute).not.toHaveBeenCalled()
616+
}
617+
)
618+
619+
it('refreshes organization policy and its server layout only within the owning organization', () => {
620+
const refreshRoute = vi.fn()
621+
const deps = makeStreamLoopDeps({ workspaceId: undefined, organizationId: 'org', refreshRoute })
622+
for (const organizationId of ['other', 'org']) {
623+
handleResourceEvent({ deps } as StreamLoopContext, {
624+
...removeEvent('file', 'unused'),
625+
payload: {
626+
op: 'refresh',
627+
resource: { type: 'settings', scope: 'organization', organizationId, id: 'access-control' },
628+
},
629+
})
630+
}
631+
expect(refreshRoute).toHaveBeenCalledOnce()
632+
expect(deps.queryClient.invalidateQueries).toHaveBeenCalledOnce()
633+
expect(deps.addResource).not.toHaveBeenCalled()
634+
})

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
hasRenderableFilePreviewContent,
1919
shouldReplaceSession,
2020
} from '@/app/workspace/[workspaceId]/home/hooks/preview'
21+
import { refreshSettings } from '@/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings'
2122
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
2223
import type {
2324
MothershipResource,
@@ -56,6 +57,22 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
5657
} = ctx.deps
5758
const onResourceEvent = onResourceEventRef.current
5859
const payload = parsed.payload
60+
if (payload.resource.type === 'settings') {
61+
const settings = payload.resource
62+
if (
63+
settings.scope === 'organization' &&
64+
(!settings.organizationId || settings.organizationId !== ctx.deps.organizationId)
65+
)
66+
return
67+
if (
68+
settings.scope === 'workspace' &&
69+
(!settings.workspaceId || (chatWorkspaceId && settings.workspaceId !== chatWorkspaceId))
70+
)
71+
return
72+
refreshSettings(queryClient, settings)
73+
if (settings.scope !== 'account' || settings.id === 'profile') ctx.deps.refreshRoute?.()
74+
return
75+
}
5976
if (payload.resource.type === 'search') {
6077
if (payload.op === 'refresh' || payload.op === 'clear_view') return
6178
const search = payload.resource.search
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { QueryClient } from '@tanstack/react-query'
2+
import type { SettingsRefresh } from '@/lib/mothership/generated/resources'
3+
import { userProfileKeys } from '@/hooks/queries/current-user-data'
4+
import { refreshGeneralSettings } from '@/hooks/queries/general-settings'
5+
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
6+
7+
/** Read canonical settings again; replay must never restore a historical setting value. */
8+
export function refreshSettings(queryClient: QueryClient, settings: SettingsRefresh): void {
9+
if (settings.scope === 'account' && settings.id === 'preferences') {
10+
void refreshGeneralSettings(queryClient)
11+
return
12+
}
13+
if (settings.scope === 'account' && settings.id === 'profile') {
14+
void queryClient.invalidateQueries({ queryKey: userProfileKeys.all })
15+
return
16+
}
17+
// Access, billing and integration policies affect resources beyond the Settings screen.
18+
// Keep live chat history stable while revalidating the rest of the current viewer's cache.
19+
void queryClient.invalidateQueries({
20+
predicate: (query) =>
21+
settings.id === 'recently-deleted' || query.queryKey[0] !== mothershipChatKeys.all[0],
22+
})
23+
}

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export interface StreamLoopDeps {
8989
workspaceId?: string
9090
organizationId?: string
9191
queryClient: QueryClient
92+
refreshRoute?: () => void
9293
assistantId: string
9394
expectedGen: number | undefined
9495
options: StreamLoopOptions

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,6 +2078,7 @@ export function useChat(
20782078
}
20792079
const clearStreamResourceActivity = () => clearResourceActivity(activityTracker, true)
20802080
const ctx = createStreamLoopContext({
2081+
refreshRoute: () => router.refresh(),
20812082
viewerId,
20822083
workspaceId,
20832084
organizationId,
@@ -2223,6 +2224,7 @@ export function useChat(
22232224
return { sawStreamError: settledError, sawComplete: state.sawCompleteEvent }
22242225
},
22252226
[
2227+
router,
22262228
viewerId,
22272229
workspaceId,
22282230
queryClient,

‎apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ vi.mock('@/app/workspace/[workspaceId]/components/resource/components/action-bar
115115
return null
116116
},
117117
}))
118-
vi.mock('@/app/workspace/[workspaceId]/components', () => ({
118+
vi.mock('@/app/workspace/[workspaceId]/components/resource/resource', () => ({
119119
Resource: Object.assign(({ children }: { children: ReactNode }) => <>{children}</>, {
120120
Header: () => null,
121121
Options: () => null,
@@ -124,6 +124,9 @@ vi.mock('@/app/workspace/[workspaceId]/components', () => ({
124124
return <>{overlay}</>
125125
},
126126
}),
127+
EMPTY_CELL_PLACEHOLDER: '',
128+
}))
129+
vi.mock('@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection', () => ({
127130
useResourceRowSelection: ({ onDeleteSelected }: { onDeleteSelected: () => void }) => {
128131
mocks.deleteKey = onDeleteSelected
129132
return {
@@ -133,14 +136,6 @@ vi.mock('@/app/workspace/[workspaceId]/components', () => ({
133136
clearSelection: vi.fn(),
134137
}
135138
},
136-
ownerCell: () => ({ label: '' }),
137-
OwnerAvatar: () => null,
138-
timeCell: () => ({ label: '' }),
139-
resourceListState: () => 'ready',
140-
selectionLabel: () => 'selected items',
141-
reportBulkOutcome: vi.fn(),
142-
EMPTY_CELL_PLACEHOLDER: '',
143-
FILTER_SECTION_LABEL_CLASS: '',
144139
}))
145140
vi.mock('@/app/workspace/[workspaceId]/components/folders/use-folder-navigation', () => ({
146141
useFolderNavigation: () => ({

‎apps/sim/hooks/queries/general-settings.test.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ vi.mock('@/lib/core/utils/theme', () => ({
1919
syncThemeToNextThemes: mockSyncTheme,
2020
}))
2121

22+
import { refreshSettings } from '@/app/workspace/[workspaceId]/home/hooks/stream/refresh-settings'
2223
import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/current-user-data'
2324
import { useGeneralSettings } from '@/hooks/queries/general-settings'
2425

@@ -76,4 +77,44 @@ describe('useGeneralSettings', () => {
7677
expect(mockSyncTheme).toHaveBeenCalledWith('dark')
7778
expect(mockRequestJson).not.toHaveBeenCalled()
7879
})
80+
it('applies settings on an organization surface with no mounted settings hook', async () => {
81+
queryClient.setQueryData(generalSettingsKeys.settings(), HYDRATED_SETTINGS)
82+
mockRequestJson.mockResolvedValue({ data: { ...HYDRATED_SETTINGS, theme: 'light' } })
83+
refreshSettings(queryClient, { type: 'settings', scope: 'account', id: 'preferences' })
84+
await vi.waitFor(() => expect(mockSyncTheme).toHaveBeenLastCalledWith('light'))
85+
expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({
86+
theme: 'light',
87+
})
88+
})
89+
90+
it('applies a Mothership preference write to the mounted UI without reloading or replaying old values', async () => {
91+
queryClient.setQueryData(generalSettingsKeys.settings(), HYDRATED_SETTINGS)
92+
act(() =>
93+
root.render(
94+
<QueryClientProvider client={queryClient}>
95+
<Probe />
96+
</QueryClientProvider>
97+
)
98+
)
99+
mockRequestJson.mockResolvedValue({ data: { ...HYDRATED_SETTINGS, theme: 'light' } })
100+
await act(async () => {
101+
refreshSettings(queryClient, { type: 'settings', scope: 'account', id: 'preferences' })
102+
await vi.waitFor(() =>
103+
expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({
104+
theme: 'light',
105+
})
106+
)
107+
})
108+
expect(mockSyncTheme).toHaveBeenLastCalledWith('light')
109+
mockRequestJson.mockResolvedValue({ data: { ...HYDRATED_SETTINGS, theme: 'system' } })
110+
await act(async () => {
111+
refreshSettings(queryClient, { type: 'settings', scope: 'account', id: 'preferences' })
112+
await vi.waitFor(() =>
113+
expect(queryClient.getQueryData(generalSettingsKeys.settings())).toMatchObject({
114+
theme: 'system',
115+
})
116+
)
117+
})
118+
expect(mockSyncTheme).toHaveBeenLastCalledWith('system')
119+
})
79120
})

‎apps/sim/hooks/queries/general-settings.ts‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,29 @@ async function fetchGeneralSettings(signal?: AbortSignal): Promise<GeneralSettin
2323
return mapGeneralSettingsResponse(data)
2424
}
2525

26+
const generalSettingsQuery = {
27+
queryKey: generalSettingsKeys.settings(),
28+
queryFn: ({ signal }: { signal: AbortSignal }) => fetchGeneralSettings(signal),
29+
staleTime: GENERAL_SETTINGS_STALE_TIME,
30+
}
31+
32+
/** Organization chat may have no mounted settings consumer; apply the current theme explicitly. */
33+
export async function refreshGeneralSettings(queryClient: QueryClient): Promise<void> {
34+
try {
35+
await queryClient.invalidateQueries({ queryKey: generalSettingsKeys.all, refetchType: 'none' })
36+
const settings = await queryClient.fetchQuery(generalSettingsQuery)
37+
syncThemeToNextThemes(settings.theme)
38+
} catch (error) {
39+
logger.error('Failed to refresh general settings', { error })
40+
}
41+
}
42+
2643
/**
2744
* Hook to fetch general settings.
2845
* TanStack Query is now the single source of truth for general settings.
2946
*/
3047
export function useGeneralSettings() {
31-
const query = useQuery({
32-
queryKey: generalSettingsKeys.settings(),
33-
queryFn: ({ signal }) => fetchGeneralSettings(signal),
34-
staleTime: GENERAL_SETTINGS_STALE_TIME,
35-
})
48+
const query = useQuery(generalSettingsQuery)
3649

3750
useEffect(() => {
3851
if (query.data?.theme) syncThemeToNextThemes(query.data.theme)
@@ -46,11 +59,7 @@ export function useGeneralSettings() {
4659
* Use on hover to warm data before navigation.
4760
*/
4861
export function prefetchGeneralSettings(queryClient: QueryClient) {
49-
queryClient.prefetchQuery({
50-
queryKey: generalSettingsKeys.settings(),
51-
queryFn: ({ signal }) => fetchGeneralSettings(signal),
52-
staleTime: GENERAL_SETTINGS_STALE_TIME,
53-
})
62+
queryClient.prefetchQuery(generalSettingsQuery)
5463
}
5564

5665
/**

‎apps/sim/lib/mothership/agent-cli/resource-effects.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ async function effectsFor(path: string, method: string, data: unknown = {}, stat
3434
}
3535

3636
describe('confirmed CLI resource effects', () => {
37+
it.each([
38+
['secrets/API_KEY', 'PUT', 'secrets'],
39+
['secrets/API_KEY', 'DELETE', 'secrets'],
40+
['credentials/connections', 'POST', 'credentials'],
41+
['credentials/id', 'PATCH', 'credentials'],
42+
['custom-tools', 'POST', 'custom-tools'],
43+
['mcp-servers/id', 'DELETE', 'mcp'],
44+
['sandboxes/id', 'PATCH', 'sandboxes'],
45+
])('refreshes settings only after a successful %s mutation', async (path, method, section) => {
46+
expect(await effectsFor(path, method)).toEqual([
47+
{ op: 'refresh', resource: { type: 'settings', scope: 'workspace', id: section } },
48+
])
49+
expect(await effectsFor(path, method, {}, 403)).toEqual([])
50+
expect(await effectsFor(path, 'GET')).toEqual([])
51+
})
52+
3753
it.each([
3854
['/', 'Report (final).md', 'files/Report%20(final).md'],
3955
['/A%2FB/100%25', 'notes.md', 'files/A%2FB/100%25/notes.md'],

‎apps/sim/lib/mothership/agent-cli/resource-effects.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import type { z } from 'zod'
22
import type { AnyApiRouteContract } from '@/lib/api/contracts/types'
3+
import * as credentials from '@/lib/api/contracts/v2/credentials'
4+
import * as customTools from '@/lib/api/contracts/v2/custom-tools'
35
import * as files from '@/lib/api/contracts/v2/files'
46
import * as knowledge from '@/lib/api/contracts/v2/knowledge'
57
import * as chunks from '@/lib/api/contracts/v2/knowledge-chunks'
68
import * as tags from '@/lib/api/contracts/v2/knowledge-tags'
79
import * as logs from '@/lib/api/contracts/v2/logs'
810
import * as logStats from '@/lib/api/contracts/v2/logs-stats'
11+
import * as mcpServers from '@/lib/api/contracts/v2/mcp-servers'
12+
import * as sandboxes from '@/lib/api/contracts/v2/sandboxes'
13+
import * as secrets from '@/lib/api/contracts/v2/secrets'
914
import * as tables from '@/lib/api/contracts/v2/tables'
1015
import * as workflows from '@/lib/api/contracts/v2/workflows'
1116
import { parseFolderPath } from '@/lib/folders/paths'
@@ -125,7 +130,42 @@ function folders(contracts: Record<string, unknown>, type: ResourceKind): Effect
125130
})
126131
}
127132

133+
function settingsMutations(
134+
section: string,
135+
contracts: Pick<AnyApiRouteContract, 'method' | 'path'>[]
136+
): EffectRoute[] {
137+
return contracts.map(({ method, path }) => ({
138+
method,
139+
path,
140+
async project() {
141+
return [{ op: 'refresh', resource: { type: 'settings', scope: 'workspace', id: section } }]
142+
},
143+
}))
144+
}
145+
128146
const EFFECT_ROUTES: EffectRoute[] = [
147+
...settingsMutations('secrets', [secrets.v2SetSecretContract, secrets.v2DeleteSecretContract]),
148+
...settingsMutations('credentials', [
149+
credentials.v2CreateCredentialConnectionContract,
150+
credentials.v2CreateServiceAccountCredentialContract,
151+
credentials.v2UpdateCredentialContract,
152+
credentials.v2DeleteCredentialContract,
153+
]),
154+
...settingsMutations('custom-tools', [
155+
customTools.v2CreateCustomToolContract,
156+
customTools.v2UpdateCustomToolContract,
157+
customTools.v2DeleteCustomToolContract,
158+
]),
159+
...settingsMutations('mcp', [
160+
mcpServers.v2CreateMcpServerContract,
161+
mcpServers.v2UpdateMcpServerContract,
162+
mcpServers.v2DeleteMcpServerContract,
163+
]),
164+
...settingsMutations('sandboxes', [
165+
sandboxes.v2CreateSandboxContract,
166+
sandboxes.v2UpdateSandboxContract,
167+
sandboxes.v2DeleteSandboxContract,
168+
]),
129169
{
130170
...after(workflows.v2GetWorkflowContract, ({ data }) => upsert('workflow', data, true)),
131171
readOnly: true,

0 commit comments

Comments
 (0)