Skip to content

Commit 366f2e9

Browse files
committed
fix(search): preserve current view and uncertain action outcomes
1 parent 4c678c7 commit 366f2e9

14 files changed

Lines changed: 159 additions & 67 deletions

File tree

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
44
import { toast } from '@sim/emcn'
55
import { toRecord } from '@sim/utils/object'
66
import { useQueryClient } from '@tanstack/react-query'
7-
import { useQueryStates } from 'nuqs'
7+
import { createSerializer, useQueryStates } from 'nuqs'
88
import { AssistantActionReviewProvider } from '@/components/assistant-actions'
99
import { requestJson } from '@/lib/api/client/request'
1010
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
@@ -16,6 +16,7 @@ import {
1616
getMothershipAttachmentUrl,
1717
} from '@/lib/mothership/chat/attachment-preview'
1818
import { createSearchResource } from '@/lib/mothership/resources/search'
19+
import { organizationRoutes } from '@/lib/navigation/paths'
1920
import { OrganizationLanding } from '@/app/o/[organizationId]/components/organization-landing'
2021
import { Composer } from '@/app/o/[organizationId]/home/components/composer'
2122
import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started'
@@ -41,6 +42,7 @@ import {
4142
import { useRestoredChatEntry } from '@/app/workspace/[workspaceId]/home/hooks/use-restored-chat-entry'
4243
import { fetchWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref'
4344
import {
45+
resourceParam,
4446
searchFilterParsers,
4547
searchFiltersFromParams,
4648
} from '@/app/workspace/[workspaceId]/home/search-params'
@@ -63,6 +65,12 @@ export interface OrganizationSummaryRequest {
6365
assistantSearch: WorkspaceSearchFilters
6466
}
6567

68+
const serializeSearchConversation = createSerializer({
69+
...organizationSearchParsers,
70+
...searchFilterParsers,
71+
[resourceParam.key]: resourceParam.parser,
72+
})
73+
6674
interface OrganizationConversationProps {
6775
active?: boolean
6876
summaryRequest?: OrganizationSummaryRequest
@@ -110,17 +118,27 @@ function OrganizationConversationContent({
110118
const [planning, setPlanning] = useState(false)
111119
const requestMode =
112120
destinationMode === 'agent' && !chatId && planEnabled && planning ? 'plan' : destinationMode
113-
const [{ q, source, updated, from, to }] = useQueryStates(
121+
const [searchParams] = useQueryStates(
114122
{ ...organizationSearchParsers, ...searchFilterParsers },
115123
organizationSearchUrlKeys
116124
)
125+
const { q, source, updated, from, to } = searchParams
117126
const controller = useResourcePanelController()
118127
const queryClient = useQueryClient()
119128
const chat = useChat(
120129
{ organizationId: organization.id },
121130
chatId,
122131
getMothershipUseChatOptions({
123132
requestMode,
133+
getChatUrl:
134+
destinationMode === 'assistant'
135+
? (id) =>
136+
serializeSearchConversation(organizationRoutes(organization.id).searchChat(id), {
137+
...searchParams,
138+
mode: active ? 'assistant' : 'search',
139+
resource: controller.activeResourceState[0],
140+
})
141+
: undefined,
124142
onResourceEvent: controller.onResourceEvent,
125143
activeResourceState: controller.activeResourceState,
126144
})

‎apps/sim/app/o/[organizationId]/integrations/utils.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,9 @@ export function assistantAccountOptions(data: OrganizationAccountsSettings, sear
2727
const providers = data.assistantProviders.filter(
2828
(provider) => !SEARCH_ACCOUNT_PROVIDERS.has(provider)
2929
)
30-
const ownedOptions = new Set(data.viewerAccounts?.map((account) => account.optionId))
3130
const configured =
3231
data.credentialGroup?.options.filter(
33-
(option) =>
34-
!SEARCH_ACCOUNT_PROVIDERS.has(option.provider) &&
35-
((option.status === 'active' && providers.includes(option.provider)) ||
36-
ownedOptions.has(option.id))
32+
(option) => !SEARCH_ACCOUNT_PROVIDERS.has(option.provider)
3733
) ?? []
3834
const query = search.trim().toLowerCase()
3935
const visible = configured.filter((option) =>

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,42 @@ const liveId = (items: AgentGroupItem[], isOpen = true) => {
1313
}
1414

1515
describe('getLaneLiveIndicator', () => {
16+
it.each([false, true])(
17+
'leaves the turn indicator visible when a settled action is lifted (nested: %s)',
18+
(nested) => {
19+
const action = call('action', 'jira_write')
20+
const items: AgentGroupItem[] = nested
21+
? [
22+
{
23+
type: 'agent_group',
24+
group: {
25+
id: 'child',
26+
agentName: 'research',
27+
agentLabel: 'Research',
28+
items: [action],
29+
isDelegating: false,
30+
isOpen: true,
31+
},
32+
},
33+
]
34+
: [action]
35+
const input = {
36+
kind: 'subagent' as const,
37+
parts: [items],
38+
isActive: true,
39+
isOpen: true,
40+
standaloneToolIds: new Set(['action']),
41+
}
42+
expect(getLaneLiveIndicator(input)).toBeUndefined()
43+
expect(
44+
getLaneLiveIndicator({
45+
...input,
46+
parts: [[...items, { type: 'text', content: 'Checking the result.' }]],
47+
})
48+
).toEqual({ type: 'narration' })
49+
}
50+
)
51+
1652
it('gives the gap to a succeeded trailing call, including search, never a failure', () => {
1753
expect(liveId([call('s', 'search_workspace'), call('a', 'read')])).toBe('a')
1854
expect(liveId([call('a', 'read'), call('s', 'search_workspace')])).toBe('s')

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,18 @@ function getMainTrailingCall(
162162
return last?.type === 'run' ? last.run.tools.at(-1) : undefined
163163
}
164164

165+
function hasVisibleLaneContent(
166+
items: AgentGroupItem[],
167+
standaloneToolIds?: ReadonlySet<string>
168+
): boolean {
169+
return items.some((item) => {
170+
if (item.type === 'tool') return !standaloneToolIds?.has(item.data.id)
171+
if (item.type === 'agent_group')
172+
return Boolean(item.group.error) || hasVisibleLaneContent(item.group.items, standaloneToolIds)
173+
return hasAgentGroupItemContent(item)
174+
})
175+
}
176+
165177
/**
166178
* The one live indicator of a lane, if it has one. See
167179
* {@link getTurnLiveIndicators} for the rule this implements.
@@ -183,7 +195,7 @@ export function getLaneLiveIndicator({
183195
if (running) return { type: 'call', tool: running }
184196
if (!isOpen) return undefined
185197
if (kind === 'subagent' && calls.length === 0) {
186-
return parts.some((items) => items.some(hasAgentGroupItemContent))
198+
return parts.some((items) => hasVisibleLaneContent(items, standaloneToolIds))
187199
? { type: 'narration' }
188200
: undefined
189201
}

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

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
11
import { organizationRoutes } from '@/lib/navigation/paths'
2-
import { organizationSearchParsers } from '@/app/o/[organizationId]/search/search-params'
3-
import {
4-
resourceParam,
5-
searchFilterParsers,
6-
} from '@/app/workspace/[workspaceId]/home/search-params'
2+
import { resourceParam } from '@/app/workspace/[workspaceId]/home/search-params'
73
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
84

9-
/** Preserve resource and Search context when a new conversation receives its chat URL. */
5+
/** Preserve the selected resource when a new conversation receives its default chat URL. */
106
export function chatUrl(
117
owner: string | { organizationId: string },
128
chatId: string,
@@ -17,17 +13,6 @@ export function chatUrl(
1713
const carried = new URLSearchParams()
1814
const resource = current.get(resourceParam.key)
1915
if (resource) carried.set(resourceParam.key, resource)
20-
if (typeof owner === 'object' && mode === 'assistant') {
21-
for (const key of Object.keys({ ...organizationSearchParsers, ...searchFilterParsers })) {
22-
const value = current.get(key)
23-
if (value) carried.set(key, value)
24-
}
25-
if (
26-
organizationSearchParsers.mode.parse(current.get('mode') ?? '') === null &&
27-
window.location.pathname === routes?.search
28-
)
29-
carried.set('mode', 'search')
30-
}
3116
const search = carried.toString()
3217
const path = routes
3318
? mode === 'assistant'

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,31 +18,16 @@ function chatSessionEvent(chatId: string): PersistedStreamEventEnvelope {
1818
}
1919

2020
describe('handleSessionEvent', () => {
21-
it.each([
22-
['assistant', '/o/organization-1/search/chat/chat-new'],
23-
['agent', '/o/organization-1/chat/chat-new'],
24-
['plan', '/o/organization-1/chat/chat-new'],
25-
] as const)(
26-
'keeps a newly created %s conversation on its owning surface',
27-
(requestMode, path) => {
28-
window.history.replaceState(null, '', '/o/organization-1/search?view=assistant')
29-
const deps = { ...makeStreamLoopDeps(), organizationId: 'organization-1', requestMode }
30-
handleSessionEvent({ deps } as StreamLoopContext, chatSessionEvent('chat-new') as never)
31-
expect(window.location.pathname).toBe(path)
32-
}
33-
)
34-
3521
it('adopts a newly assigned chat before chatIdRef is written', () => {
3622
const deps = makeStreamLoopDeps()
37-
deps.workflowIdRef.current = 'workflow-1'
3823
vi.mocked(deps.adoptResolvedChatId).mockImplementation((chatId) => {
3924
expect(deps.chatIdRef.current).toBeUndefined()
4025
deps.chatIdRef.current = chatId
4126
})
4227

4328
handleSessionEvent({ deps } as StreamLoopContext, chatSessionEvent('chat-new') as never)
4429

45-
expect(deps.adoptResolvedChatId).toHaveBeenCalledWith('chat-new')
30+
expect(deps.adoptResolvedChatId).toHaveBeenCalledWith('chat-new', { replaceHomeHistory: true })
4631
expect(deps.chatIdRef.current).toBe('chat-new')
4732
})
4833
})

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

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
22
import { MothershipStreamV1SessionKind } from '@/lib/mothership/generated/mothership-stream-v1'
33
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
4-
import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url'
54
import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context'
65
import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats'
76

@@ -24,7 +23,7 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent)
2423
// This synchronously migrates provisional browser/terminal ownership
2524
// before writing chatIdRef. Assigning the ref here first would make the
2625
// adoption path believe there was no provisional scope to move.
27-
deps.adoptResolvedChatId(payloadChatId)
26+
deps.adoptResolvedChatId(payloadChatId, { replaceHomeHistory: true })
2827
} else {
2928
deps.chatIdRef.current = payloadChatId
3029
if (payloadChatId === selected) {
@@ -61,17 +60,6 @@ export function handleSessionEvent(ctx: StreamLoopContext, parsed: SessionEvent)
6160
)
6261
}
6362
deps.setPendingMessages([])
64-
if (!deps.workflowIdRef.current) {
65-
window.history.replaceState(
66-
null,
67-
'',
68-
chatUrl(
69-
deps.organizationId ? { organizationId: deps.organizationId } : deps.workspaceId!,
70-
payloadChatId,
71-
deps.requestMode
72-
)
73-
)
74-
}
7563
}
7664
}
7765

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export interface StreamLoopDeps {
115115
setPendingMessages: Dispatch<SetStateAction<ChatMessage[]>>
116116
setResolvedChatId: Dispatch<SetStateAction<string | undefined>>
117117
/** Adopts a server-created chat before any event handler mutates chatIdRef. */
118-
adoptResolvedChatId: (chatId: string) => void
118+
adoptResolvedChatId: (chatId: string, options?: { replaceHomeHistory?: boolean }) => void
119119
setResources: Dispatch<SetStateAction<MothershipResource[]>>
120120
setActiveResourceId: Dispatch<SetStateAction<string | null>>
121121

@@ -185,7 +185,6 @@ export interface StreamLoopDeps {
185185
pendingUserMsgRef: MutableRefObject<PersistedMessage | null>
186186
activeTurnRef: MutableRefObject<ActiveTurn | null>
187187
resourcesRef: MutableRefObject<MothershipResource[]>
188-
workflowIdRef: MutableRefObject<string | undefined>
189188
activeResourceIdRef: MutableRefObject<string | null>
190189
onTitleUpdateRef: MutableRefObject<(() => void) | undefined>
191190
onToolResultRef: MutableRefObject<

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export function makeStreamLoopDeps(overrides: Partial<StreamLoopDeps> = {}): Str
7979
pendingUserMsgRef: ref<PersistedMessage | null>(null),
8080
activeTurnRef: ref<ActiveTurn | null>(null),
8181
resourcesRef: ref<MothershipResource[]>([]),
82-
workflowIdRef: ref<string | undefined>(undefined),
8382
activeResourceIdRef: ref<string | null>(null),
8483
onTitleUpdateRef: ref<(() => void) | undefined>(undefined),
8584
onToolResultRef: ref<

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,8 @@ export function shouldQueueOutgoingMessage(
657657
export interface UseChatOptions {
658658
/** Intent for new turns; persisted chat intent remains authoritative on reload. */
659659
requestMode?: ChatRequestMode
660+
/** Build the admitted conversation URL from the owning surface's current view state. */
661+
getChatUrl?: (chatId: string) => string
660662
onResourceEvent?: ResourceEventHandler
661663
apiPath?: string
662664
stopPath?: string
@@ -707,6 +709,7 @@ export function getMothershipUseChatOptions(
707709
| 'activeResourceState'
708710
| 'onRequestStarted'
709711
| 'requestMode'
712+
| 'getChatUrl'
710713
> = {}
711714
): UseChatOptions {
712715
return {
@@ -771,6 +774,8 @@ export function useChat(
771774
const [activeResourceId, setActiveResourceId] =
772775
options?.activeResourceState ?? internalActiveResourceState
773776
const onResourceEventRef = useRef(options?.onResourceEvent)
777+
const getChatUrlRef = useRef(options?.getChatUrl)
778+
getChatUrlRef.current = options?.getChatUrl
774779
const revealedSimKeysRef = useRef<RevealedSimKeysByMessage | null>(null)
775780
const revealedSimKeys = (revealedSimKeysRef.current ??= new Map())
776781
onResourceEventRef.current = options?.onResourceEvent
@@ -1367,11 +1372,12 @@ export function useChat(
13671372
window.history.replaceState(
13681373
null,
13691374
'',
1370-
chatUrl(
1371-
organizationId ? { organizationId } : workspaceId!,
1372-
chatId,
1373-
requestModeRef.current
1374-
)
1375+
getChatUrlRef.current?.(chatId) ??
1376+
chatUrl(
1377+
organizationId ? { organizationId } : workspaceId!,
1378+
chatId,
1379+
requestModeRef.current
1380+
)
13751381
)
13761382
}
13771383
if (options?.invalidateList) {
@@ -2414,7 +2420,6 @@ export function useChat(
24142420
pendingUserMsgRef,
24152421
activeTurnRef,
24162422
resourcesRef,
2417-
workflowIdRef,
24182423
activeResourceIdRef,
24192424
onTitleUpdateRef,
24202425
onToolResultRef,

0 commit comments

Comments
 (0)