Skip to content

Commit 819cee7

Browse files
committed
fix(search): align activity headers and preserve complete drafts
1 parent 1d28f43 commit 819cee7

12 files changed

Lines changed: 135 additions & 20 deletions

File tree

‎apps/sim/app/o/[organizationId]/home/organization-home.test.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,15 @@ it('waits for the user before first rendering their saved mode and level', async
170170
expect(composerProps()).not.toHaveProperty('assistantSearchLevel')
171171
})
172172

173+
async function attachDraftImage() {
174+
const files = [new File(['image'], 'draft.png', { type: 'image/png' })]
175+
await act(async () =>
176+
composerProps().files.processFiles(
177+
Object.assign(files, { item: (index: number) => files[index] ?? null })
178+
)
179+
)
180+
}
181+
173182
function hasCompletedMcpStep() {
174183
const link = container.querySelector('a[href="/o/organization-a/settings/search-mcp"]')
175184
expect(link).not.toBeNull()
@@ -914,8 +923,11 @@ it.each(['agent', 'plan', 'assistant'] as const)(
914923
async (mode) => {
915924
mocks.plan = true
916925
await act(async () => renderHome(<OrganizationHome requestMode={mode} />))
926+
await attachDraftImage()
917927
await act(async () => composerProps().onChange('Unsent launch follow-up'))
918928
expect(localStorage.getItem('mothership-drafts:v1')).toContain('Unsent launch follow-up')
929+
expect(localStorage.getItem('mothership-drafts:v1')).toContain('image-key')
930+
expect(localStorage.getItem('mothership-drafts:v1')).not.toContain('blob:')
919931
await act(async () =>
920932
composerProps().onModeChange?.(mode === 'assistant' ? 'agent' : 'assistant')
921933
)
@@ -928,20 +940,30 @@ it.each(['agent', 'plan', 'assistant'] as const)(
928940
root = createRoot(container)
929941
await act(async () => renderHome(<OrganizationHome requestMode={mode} />))
930942
expect(composerProps().value).toBe('Unsent launch follow-up')
943+
expect(composerProps().files.attachedFiles).toEqual([
944+
expect.objectContaining({
945+
key: 'image-key',
946+
uploading: false,
947+
previewUrl: '/api/files/serve/image-key?context=mothership&preview=1',
948+
}),
949+
])
931950
await act(async () => composerProps().onSubmit(composerProps().value))
932951
expect(composerProps().value).toBe('')
933952
expect(localStorage.getItem('mothership-drafts:v1')).not.toContain('Unsent launch follow-up')
953+
expect(localStorage.getItem('mothership-drafts:v1')).not.toContain('image-key')
934954
}
935955
)
936956

937957
it('isolates saved drafts by user, organization, and conversation', async () => {
938958
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
939959
await act(async () => renderHome(<OrganizationHome chatId='chat-a' requestMode='assistant' />))
960+
await attachDraftImage()
940961
await act(async () => composerProps().onChange('Chat A follow-up'))
941962
await act(async () => renderHome(<OrganizationHome chatId='chat-b' requestMode='assistant' />))
942963
expect(composerProps().value).toBe('')
943964
await act(async () => renderHome(<OrganizationHome chatId='chat-a' requestMode='assistant' />))
944965
expect(composerProps().value).toBe('Chat A follow-up')
966+
expect(composerProps().files.attachedFiles[0]?.key).toBe('image-key')
945967
mocks.session.mockReturnValue({ data: { user: { id: 'other-user' } } })
946968
await act(async () => renderHome(<OrganizationHome chatId='chat-a' requestMode='assistant' />))
947969
expect(composerProps().value).toBe('')
@@ -958,15 +980,36 @@ it('moves a follow-up draft to the newly resolved conversation before it is reop
958980
mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer)
959981
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
960982
await act(async () => composerProps().onSubmit('First question'))
983+
await attachDraftImage()
961984
await act(async () => composerProps().onChange('Follow-up typed before admission'))
962985
mocks.chat.mockReturnValue({ ...mocks.chat(), resolvedChatId: 'adopted-chat' })
963986
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
964987
expect(composerProps().value).toBe('Follow-up typed before admission')
988+
expect(composerProps().files.attachedFiles[0]?.key).toBe('image-key')
965989
expect(
966990
useMothershipDraftsStore.getState().drafts['reader:organization:organization-a:new']
967991
).toBeUndefined()
968992
await act(async () =>
969993
renderHome(<OrganizationHome chatId='adopted-chat' requestMode='assistant' />)
970994
)
971995
expect(composerProps().value).toBe('Follow-up typed before admission')
996+
expect(composerProps().files.attachedFiles[0]?.key).toBe('image-key')
997+
})
998+
999+
it('keeps an attachment-only draft across remounts and clears it when its last file is removed', async () => {
1000+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
1001+
await attachDraftImage()
1002+
await act(async () => composerProps().onChange('temporary text'))
1003+
await act(async () => composerProps().onChange(''))
1004+
await act(async () => root.unmount())
1005+
root = createRoot(container)
1006+
await act(async () => renderHome(<OrganizationHome requestMode='assistant' />))
1007+
expect(composerProps().value).toBe('')
1008+
const attached = composerProps().files.attachedFiles[0]
1009+
expect(attached.key).toBe('image-key')
1010+
await act(async () => composerProps().files.removeFile(attached.id))
1011+
expect(
1012+
useMothershipDraftsStore.getState().drafts['reader:organization:organization-a:new']
1013+
).toBeUndefined()
1014+
expect(composerProps().files.attachedFiles).toEqual([])
9721015
})

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,20 @@ function OrganizationHomeContent({
109109
})
110110
const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}`
111111
const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}`
112+
const savedDraft = useMothershipDraftsStore.getState().drafts
113+
const initialDraft = savedDraft[draftKey] ?? savedDraft[initialDraftKey]
112114
const draft = useMothershipDraftsStore(
113115
(state) => (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? ''
114116
)
115117
const setDraft = useCallback(
116118
(text: string, contexts?: ChatContext[]) => {
117-
useMothershipDraftsStore.getState().setDraft(draftKey, { text, contexts })
119+
const store = useMothershipDraftsStore.getState()
120+
store.setDraft(draftKey, { ...store.drafts[draftKey], text, contexts })
118121
},
119122
[draftKey]
120123
)
121124
const [restoredContexts, setRestoredContexts] = useState<ChatContext[]>(
122-
() => useMothershipDraftsStore.getState().drafts[draftKey]?.contexts ?? []
125+
() => initialDraft?.contexts ?? []
123126
)
124127
useEffect(() => {
125128
useMothershipDraftsStore.getState().migrateDraft(initialDraftKey, draftKey)
@@ -189,7 +192,25 @@ function OrganizationHomeContent({
189192
userId: session?.user?.id,
190193
organizationId: organization.id,
191194
requestMode,
195+
initialAttachments: initialDraft?.fileAttachments,
192196
})
197+
useEffect(() => {
198+
const store = useMothershipDraftsStore.getState()
199+
const fileAttachments = files.attachedFiles
200+
.filter((file) => !file.uploading && file.key)
201+
.map((file) => ({
202+
id: file.id,
203+
key: file.key!,
204+
filename: file.name,
205+
media_type: file.type,
206+
size: file.size,
207+
path: file.path,
208+
}))
209+
store.setDraft(draftKey, {
210+
...(store.drafts[draftKey] ?? { text: '' }),
211+
fileAttachments,
212+
})
213+
}, [draftKey, files.attachedFiles])
193214
useEffect(() => {
194215
if (chat.error) toast.error(chat.error)
195216
}, [chat.error])
@@ -262,7 +283,7 @@ function OrganizationHomeContent({
262283
path: file.path,
263284
}))
264285
if (!message && !attachments.length) return
265-
setDraft('')
286+
useMothershipDraftsStore.getState().clearDraft(draftKey)
266287
send(message, attachments.length ? attachments : undefined, contexts)
267288
setRestoredContexts([])
268289
files.clearAttachedFiles()
@@ -320,7 +341,7 @@ function OrganizationHomeContent({
320341
if (queued) {
321342
const queuedMode = queued.requestMode ?? requestMode
322343
setSelectedMode(queuedMode)
323-
setDraft(queued.content)
344+
setDraft(queued.content, queued.contexts)
324345
setRestoredContexts(queued.contexts ?? [])
325346
files.restoreAttachedFiles(
326347
(queued.fileAttachments ?? []).map((file) => ({

‎apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ vi.mock('@/hooks/queries/organization-secrets', () => ({
2323
useRemoveOrganizationSecretSource: () => ({ mutate: vi.fn() }),
2424
}))
2525
vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.policies }))
26+
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
27+
useOrganizationContext: () => ({ organization: { id: 'org', name: 'Example Organization' } }),
28+
}))
2629
vi.mock('@/app/o/[organizationId]/integrations/disconnect-account-menu', () => ({
2730
DisconnectAccountMenu: ({ accounts }: { accounts: { displayName: string }[] }) => (
2831
<span>{accounts.map((account) => account.displayName).join(', ')}</span>

‎apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,18 @@ function SearchResults({
255255
</ChipLink>
256256
</div>
257257
) : (
258-
<div className='flex flex-col'>
258+
<div aria-busy={!awaitingRange && fetching} className='flex flex-col'>
259259
<div className='flex items-center gap-2 px-2 py-2'>
260260
<div className='min-w-0 flex-1'>
261261
{awaitingRange ? (
262262
<p role='status' className='text-[var(--text-muted)] text-caption'>
263263
Choose the days to search.
264264
</p>
265-
) : fetching || (pending && !failed) ? null : (
265+
) : fetching ? (
266+
<p role='status' className='sr-only'>
267+
{pending ? 'Searching…' : 'Updating results…'}
268+
</p>
269+
) : pending && !failed ? null : (
266270
<p role='status' className='text-[var(--text-muted)] text-caption'>
267271
{failed
268272
? 'Search couldn’t run.'
@@ -442,7 +446,12 @@ function LiveSearchResults({
442446
]),
443447
]
444448
return (
445-
<div className='flex flex-col'>
449+
<div aria-busy={!awaitingRange && isFetching} className='flex flex-col'>
450+
{!awaitingRange && isFetching && (
451+
<p role='status' className='sr-only'>
452+
{isPending ? 'Searching…' : 'Updating results…'}
453+
</p>
454+
)}
446455
{(awaitingRange || isError) && (
447456
<div className='flex items-center gap-2 px-2 py-2'>
448457
{awaitingRange ? (

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { cn, OverflowText } from '@sim/emcn'
4+
import { cn } from '@sim/emcn'
55
import { Search } from '@sim/emcn/icons'
66
import { toStringOrNull } from '@sim/utils/coerce'
77
import { toArray, toRecord } from '@sim/utils/object'
8+
import { ActivityStatus } from '@/components/ui/activity-status'
89
import {
910
collectRetrievalCitationEvidence,
1011
parseCitationRecord,
@@ -91,10 +92,7 @@ function SearchQueryActivity({ tool }: SearchQueryActivityProps) {
9192
return (
9293
<ActivityDisclosure
9394
header={
94-
<span className='flex min-w-0 items-center gap-2 text-[var(--text-muted)] text-small'>
95-
<Search aria-hidden className='size-[14px] shrink-0 text-[var(--text-icon)]' />
96-
<OverflowText label={label} focusTarget='nearest-interactive' />
97-
</span>
95+
<ActivityStatus label={label} isActive={false} icon={<Search className='size-[14px]' />} />
9896
}
9997
expanded={expanded}
10098
onToggle={() => setExpanded(!expanded)}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/search-resource-content.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async function render(query: string, nativeQueries?: [{ provider: 'github'; quer
118118
await vi.waitFor(async () => {
119119
await act(async () => {})
120120
expect(client.isFetching()).toBe(0)
121-
expect(container.textContent).not.toContain('Searching…')
121+
expect(container.querySelector('[aria-busy="true"]')).toBeNull()
122122
})
123123
}
124124
describe('shared Search resource content', () => {

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import {
1717
} from '@/lib/uploads/shared/assistant-images'
1818
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
1919
import { resolveFileType } from '@/lib/uploads/utils/file-utils'
20-
import type { ChatRequestMode } from '@/app/workspace/[workspaceId]/home/types'
20+
import type {
21+
ChatRequestMode,
22+
FileAttachmentForApi,
23+
} from '@/app/workspace/[workspaceId]/home/types'
2124

2225
const logger = createLogger('useFileAttachments')
2326

@@ -89,6 +92,7 @@ interface UseFileAttachmentsProps {
8992
requestMode?: ChatRequestMode
9093
disabled?: boolean
9194
isLoading?: boolean
95+
initialAttachments?: FileAttachmentForApi[]
9296
}
9397

9498
/**
@@ -102,11 +106,22 @@ export function useFileAttachments(props: UseFileAttachmentsProps) {
102106
const { userId, workspaceId, organizationId, requestMode, disabled, isLoading } = props
103107
const imagesOnly = Boolean(organizationId) && requestMode !== 'agent' && requestMode !== 'plan'
104108

105-
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
109+
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>(() =>
110+
(props.initialAttachments ?? []).map((file) => ({
111+
id: file.id,
112+
name: file.filename,
113+
size: file.size,
114+
type: file.media_type,
115+
key: file.key,
116+
path: file.path || getMothershipAttachmentPreviewUrl(file) || '',
117+
previewUrl: getMothershipAttachmentPreviewUrl(file),
118+
uploading: false,
119+
}))
120+
)
106121
const [dragCounter, setDragCounter] = useState(0)
107122
const isDragging = dragCounter > 0
108123
const fileInputRef = useRef<HTMLInputElement>(null)
109-
const attachedFilesRef = useRef<AttachedFile[]>([])
124+
const attachedFilesRef = useRef<AttachedFile[]>(attachedFiles)
110125
const uploadControllersRef = useRef(new Map<string, AbortController>())
111126

112127
const updateAttachedFiles = useCallback((update: (files: AttachedFile[]) => AttachedFile[]) => {

‎apps/sim/stores/mothership-drafts/store.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface MothershipDraftsState {
2222
setDraft: (key: string, payload: DraftPayload) => void
2323
clearDraft: (key: string) => void
2424
migrateDraft: (fromKey: string, toKey: string) => void
25+
reset: () => void
2526
}
2627

2728
const LEGACY_WORKFLOW_COPILOT_KEY = /^[^:]+:workflow-copilot:[^:]+$/
@@ -58,6 +59,7 @@ export const useMothershipDraftsStore = create<MothershipDraftsState>()(
5859
persist(
5960
(set) => ({
6061
drafts: {},
62+
reset: () => set({ drafts: {} }),
6163
setDraft: (key, payload) =>
6264
set((s) => {
6365
if (isEmpty(payload)) {

‎apps/sim/stores/reset-all-stores.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
mockClearAllExecutionPointers,
1111
mockGetQueryClient,
1212
mockMothershipQueueReset,
13+
mockMothershipDraftsReset,
1314
mockOperationQueueReset,
1415
mockResetRegisteredUserData,
1516
mockRegistrySetState,
@@ -22,6 +23,7 @@ const {
2223
mockConsoleReset: vi.fn(),
2324
mockGetQueryClient: vi.fn(),
2425
mockMothershipQueueReset: vi.fn(),
26+
mockMothershipDraftsReset: vi.fn(),
2527
mockOperationQueueReset: vi.fn(),
2628
mockResetRegisteredUserData: vi.fn(),
2729
mockRegistrySetState: vi.fn(),
@@ -40,7 +42,7 @@ vi.mock('@/stores/execution', () => ({
4042
useExecutionStore: { getState: () => ({ reset: vi.fn() }) },
4143
}))
4244
vi.mock('@/stores/mothership-drafts/store', () => ({
43-
useMothershipDraftsStore: { setState: vi.fn() },
45+
useMothershipDraftsStore: { getState: () => ({ reset: mockMothershipDraftsReset }) },
4446
}))
4547
vi.mock('@/stores/mothership-queue/store', () => ({
4648
useMothershipQueueStore: { getState: () => ({ reset: mockMothershipQueueReset }) },
@@ -104,6 +106,7 @@ describe('resetAllStores', () => {
104106
expect(mockConsoleReset).toHaveBeenCalledOnce()
105107
expect(mockClearAllExecutionPointers).toHaveBeenCalledOnce()
106108
expect(mockMothershipQueueReset).toHaveBeenCalledOnce()
109+
expect(mockMothershipDraftsReset).toHaveBeenCalledOnce()
107110
expect(mockConsolePersist).toHaveBeenCalledWith({ merge: false })
108111
})
109112

‎apps/sim/stores/reset-all-stores.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function resetAllStores(): Promise<void> {
5353
})
5454
consolePersistence.reset()
5555
clearAllExecutionPointers()
56-
useMothershipDraftsStore.setState({ drafts: {} })
56+
useMothershipDraftsStore.getState().reset()
5757
useMothershipQueueStore.getState().reset()
5858
await consolePersistence.persist({ merge: false })
5959
}

0 commit comments

Comments
 (0)