From 77ff3ba88e4f6517352d560b5179d7b570820147 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 23 Sep 2026 20:08:10 -0700 Subject: [PATCH 1/3] improvement(search): polish organization search and source results --- apps/sim/app/layout.tsx | 4 +- .../chats-section/chats-section.tsx | 22 +- .../workspace-list.test.tsx | 4 +- .../workspaces-section/workspace-list.tsx | 7 - .../organization-sidebar/hooks/index.ts | 1 - .../hooks/use-collapsed-tooltips.ts | 23 -- .../organization-sidebar.tsx | 7 +- .../components/search-input-bar.tsx | 4 +- .../components/composer/composer.test.tsx | 14 +- .../home/components/composer/composer.tsx | 54 ++--- .../home/organization-home.test.tsx | 64 ++++++ .../home/organization-home.tsx | 29 ++- .../app/o/[organizationId]/search/page.tsx | 2 +- .../o/[organizationId]/search/search.test.tsx | 45 +++- .../app/o/[organizationId]/search/search.tsx | 35 ++- .../add-organization-source-modal.tsx | 16 +- .../integrations/generic-secret-source.tsx | 18 +- .../live-search-settings.test.tsx | 2 +- .../components/invite-modal/invite-modal.tsx | 1 - .../use-sidebar-peek.test.tsx | 36 +-- .../workspace-chrome/use-sidebar-peek.ts | 55 +---- .../workspace-chrome/workspace-chrome.tsx | 46 +--- .../knowledge-search-results.tsx | 9 +- .../search-transitions.test.tsx | 14 +- .../agent-group/main-agent-activity.tsx | 29 ++- .../agent-group/search-activity-results.tsx | 78 +++++++ .../agent-group/search-activity.test.tsx | 212 ++++++++++++++++++ .../agent-group/search-activity.tsx | 140 ++++++++++++ .../components/chat-content/chat-content.tsx | 4 +- .../components/chat-content/external-link.tsx | 24 +- .../components/source-card/index.ts | 4 +- .../components/source-card/source-card.tsx | 207 +++++++---------- .../components/source-chip/index.ts | 2 +- .../source-chip/source-chip.test.tsx | 51 +++++ .../components/source-chip/source-chip.tsx | 34 +-- .../components/source-chip/source-icon.tsx | 49 ++++ .../components/sources-resource-content.tsx | 42 ++-- .../components/conversation-mode-selector.tsx | 14 +- .../home/components/user-input/user-input.tsx | 16 +- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../stream/handle-complete-event.test.ts | 29 ++- .../hooks/stream/handle-complete-event.ts | 36 +-- .../stream/handle-resource-event.test.ts | 11 +- .../hooks/stream/handle-resource-event.ts | 19 +- .../home/hooks/use-chat.mount-send.test.tsx | 35 +++ .../[workspaceId]/home/hooks/use-chat.ts | 32 ++- .../workspace-header/workspace-header.tsx | 12 +- .../w/components/sidebar/sidebar.tsx | 70 +----- .../settings/standalone-settings-shell.tsx | 11 +- apps/sim/hooks/use-animated-placeholder.ts | 2 +- .../lib/mothership/chat/citation-evidence.ts | 17 +- apps/sim/stores/constants.ts | 8 +- apps/sim/stores/mothership-drafts/store.ts | 20 +- apps/sim/stores/sidebar/store.test.ts | 9 +- apps/sim/stores/sidebar/store.ts | 8 +- 55 files changed, 1145 insertions(+), 594 deletions(-) delete mode 100644 apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-icon.tsx diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 12b5a343521..7fc27f4970f 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -112,7 +112,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // needs the same variables set before first paint. try { var path = window.location.pathname; - if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0) { + if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0 && path.indexOf('/account/settings') !== 0 && path.indexOf('/selfhost/settings') !== 0) { return; } } catch (e) { @@ -123,7 +123,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // 30% of the viewport capped at 400px, and never below the 224px // minimum, so a narrow window yields a width >= MIN instead of a // sub-minimum sliver. - var defaultSidebarWidth = 256; + var defaultSidebarWidth = 224; try { // Collapse comes from the cookie (independent of localStorage // parsing); the persisted width is read defensively below. Match the diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index b349e283201..51e7cf33625 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,6 +1,6 @@ 'use client' -import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { chipVariants, cn, DropdownMenuItem, OverflowText } from '@sim/emcn' import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions' @@ -20,15 +20,6 @@ import { SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -/** Stands in for a chip row while the list loads, so it carries no margin either. */ -function ChatRowSkeleton() { - return ( -
- -
- ) -} - interface ChatRowProps { chat: OrganizationChat isCurrentRoute: boolean @@ -132,12 +123,7 @@ export function ChatsSection({ ariaLabel='Chats' isEditing={rename.editingId !== null} > - {isLoading ? ( - - - Loading... - - ) : chats.length === 0 ? ( + {isLoading ? null : chats.length === 0 ? ( No chats yet ) : ( chats.map((chat) => ( @@ -163,9 +149,7 @@ export function ChatsSection({ ) : (
- {isLoading ? ( - - ) : ( + {!isLoading && ( <> {chats.length === 0 && (
diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx index 2a08c775fb9..7812a96d3e4 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.test.tsx @@ -146,9 +146,9 @@ describe('WorkspaceList rail view', () => { expect(document.querySelector('input')).toBeNull() }) - it('shows the loading row while the list resolves', async () => { + it('keeps the list quiet while it resolves', async () => { workspacesState.isLoading = true await render() - expect(document.body.textContent).toContain('Loading...') + expect(document.body.textContent).not.toContain('Loading...') }) }) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx index 3cb648f6f33..d969ab325ff 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspace-list.tsx @@ -6,7 +6,6 @@ import { cn, DropdownMenuItem, DropdownMenuItemAction, - Loader, OverflowText, toast, } from '@sim/emcn' @@ -76,12 +75,6 @@ export function WorkspaceList({ organizationId, pathname, flyout }: WorkspaceLis return ( <> - {isLoading && flyout && ( - - - Loading... - - )} {!isLoading && workspaces.length === 0 && (
No workspaces yet
)} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts index c96914ad41e..3dee5e2faea 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts @@ -1,4 +1,3 @@ -export { useCollapsedTooltips } from './use-collapsed-tooltips' export type { OrganizationChat } from './use-organization-chats' export { useOrganizationChats } from './use-organization-chats' export { useOrganizationWorkspaces } from './use-organization-workspaces' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts deleted file mode 100644 index b63dc15c591..00000000000 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useState } from 'react' - -/** How long the rail takes to settle after collapsing before row tooltips arm. */ -const COLLAPSED_TOOLTIP_DELAY_MS = 200 - -/** - * Whether collapsed-rail tooltips should render. Arming is delayed past the rail's - * width animation so a tooltip never flashes beside a label that is still fading - * out; disarming is immediate so the expanded rail never shows one. - */ -export function useCollapsedTooltips(isCollapsed: boolean): boolean { - const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) - - useEffect(() => { - if (isCollapsed) { - const timer = setTimeout(() => setShowCollapsedTooltips(true), COLLAPSED_TOOLTIP_DELAY_MS) - return () => clearTimeout(timer) - } - setShowCollapsedTooltips(false) - }, [isCollapsed]) - - return isCollapsed && showCollapsedTooltips -} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx index c17dd4b6735..98a56a4c27e 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -16,10 +16,7 @@ import { OrganizationHeader, WorkspacesSection, } from '@/app/o/[organizationId]/components/organization-sidebar/components' -import { - useCollapsedTooltips, - useOrganizationChats, -} from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { useOrganizationChats } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar' @@ -85,7 +82,7 @@ export const OrganizationSidebar = memo(function OrganizationSidebar() { useOrganizationContext() const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const { handlePointerDown } = useSidebarResize() - const showCollapsedTooltips = useCollapsedTooltips(isCollapsed) + const showCollapsedTooltips = isCollapsed const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef, enabled: !isCollapsed, diff --git a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx index 27df8546fb1..f62940e3d1f 100644 --- a/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx +++ b/apps/sim/app/o/[organizationId]/components/search-input-bar.tsx @@ -81,8 +81,8 @@ export function SearchInputBar({
diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index e06572cab86..a59f9923b53 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -617,15 +617,15 @@ it('shows Build with a chevron in the shared chip and text-only modes in its men onModeChange, }) const mode = container.querySelector('[aria-label="Conversation mode"]')! - expect(mode.parentElement?.previousElementSibling).toBeNull() - expect(mode.parentElement?.nextElementSibling?.getAttribute('aria-label')).toBe('Add resources') + expect(mode.parentElement?.previousElementSibling?.getAttribute('aria-label')).toBe('Skills') + expect(mode.parentElement?.nextElementSibling).toBeNull() expect(mode.textContent).toBe('Build') expect(mode.querySelectorAll('svg')).toHaveLength(1) await act(async () => mode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) ) const search = [...document.querySelectorAll('[role="menuitem"]')].find( - (item) => item.textContent === 'Search' + (item) => item.textContent === 'Ask' )! expect(search.querySelector('svg')).toBeNull() expect(document.querySelector('[role="tooltip"]')).toBeNull() @@ -729,12 +729,12 @@ it.each(['skill', 'file'] as const)( mode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) ) const search = [...document.querySelectorAll('[role="menuitem"]')].find( - (item) => item.textContent === 'Search' + (item) => item.textContent === 'Ask' )! await act(async () => search.click()) expect(onModeChange).not.toHaveBeenCalled() expect(info).toHaveBeenCalledWith( - 'Remove resource and skill mentions and non-image attachments before switching to Search.' + 'Remove resource and skill mentions and non-image attachments before switching to Ask.' ) expect( container.querySelector('[aria-label="Ask Sim"]')! @@ -786,8 +786,8 @@ describe('Search levels', () => { expect( row.querySelector('[aria-label="Ask Sim"]') ).not.toBeNull() - expect(mode.textContent).toBe('') - expect(mode.querySelectorAll('svg')).toHaveLength(2) + expect(mode.textContent).toBe('Ask') + expect(mode.querySelectorAll('svg')).toHaveLength(1) expect(mode.parentElement?.nextElementSibling).toBeNull() expect(row.querySelector('[aria-label="Search level"]')).toBeNull() }) diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index 30da4afe19c..0193d2ae948 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -43,7 +43,7 @@ interface ComposerProps { /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */ isInitialView: boolean isSending: boolean - onChange: (value: string) => void + onChange: (value: string, contexts?: ChatContext[]) => void restoredContexts?: ChatContext[] onSubmit: (text: string, contexts?: ChatContext[]) => void onStop: () => void @@ -91,6 +91,7 @@ export function Composer({ organizationId: organization.id, contextsEnabled: !imagesOnly, initialValue: value, + initialContexts: restoredContexts, onPasteFiles: files.processFiles, }) const { textareaRef } = editor @@ -105,13 +106,16 @@ export function Composer({ if (value) textareaRef.current?.focus() } }, [value, textareaRef]) + const lastContexts = useRef(editor.contexts) useEffect(() => { if (editorRef.current.getValue() !== editor.value) return - if (editor.value !== lastPublished.current) { - lastPublished.current = editor.value - onChange(editor.value) + const plainValue = editorRef.current.getPlainValue() + if (plainValue !== lastPublished.current || editor.contexts !== lastContexts.current) { + lastPublished.current = plainValue + lastContexts.current = editor.contexts + onChange(plainValue, editor.contexts.length ? editor.contexts : undefined) } - }, [editor.value, onChange]) + }, [editor.value, editor.contexts, onChange]) useEffect(() => { if (!restoredContexts) return // A queued skill may belong to a workspace whose picker has never opened here. @@ -182,6 +186,25 @@ export function Composer({ const leadingControls = ( <> + {imagesOnly && !showModeSelector && ( + + )} + {!imagesOnly && contextPicker('resources', Plus, 'Add resources')} + + {!imagesOnly && ( + + + + + Attach file + + )} + {!imagesOnly && contextPicker('skills', Slash, 'Skills')} {showModeSelector && ( !isAssistantImageType(file.type))) ) { toast.info( - 'Remove resource and skill mentions and non-image attachments before switching to Search.' + 'Remove resource and skill mentions and non-image attachments before switching to Ask.' ) return } @@ -205,25 +228,6 @@ export function Composer({ } /> )} - {imagesOnly && !showModeSelector && ( - - )} - {!imagesOnly && contextPicker('resources', Plus, 'Add resources')} - - {!imagesOnly && ( - - - - - Attach file - - )} - {!imagesOnly && contextPicker('skills', Slash, 'Skills')} ) const voiceControl = voice.isSupported && ( diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 5784bb16076..badb5d4a5eb 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToString } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AuthorizedApp, AuthorizedAppsPage } from '@/lib/api/contracts/user' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' const mocks = vi.hoisted(() => ({ @@ -93,6 +94,7 @@ import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-hom let root: Root let container: HTMLDivElement beforeEach(() => { + useMothershipDraftsStore.setState({ drafts: {} }) mocks.plan = false mocks.live = false vi.clearAllMocks() @@ -906,3 +908,65 @@ it.each(['adaptive', 'fast', 'max'] as const)( ) } ) + +it.each(['agent', 'plan', 'assistant'] as const)( + 'persists the %s Home draft across reloads and mode changes', + async (mode) => { + mocks.plan = true + await act(async () => renderHome()) + await act(async () => composerProps().onChange('Unsent launch follow-up')) + expect(localStorage.getItem('mothership-drafts:v1')).toContain('Unsent launch follow-up') + await act(async () => + composerProps().onModeChange?.(mode === 'assistant' ? 'agent' : 'assistant') + ) + expect(composerProps().value).toBe('Unsent launch follow-up') + await act(async () => root.unmount()) + const saved = localStorage.getItem('mothership-drafts:v1')! + useMothershipDraftsStore.setState({ drafts: {} }) + localStorage.setItem('mothership-drafts:v1', saved) + await useMothershipDraftsStore.persist.rehydrate() + root = createRoot(container) + await act(async () => renderHome()) + expect(composerProps().value).toBe('Unsent launch follow-up') + await act(async () => composerProps().onSubmit(composerProps().value)) + expect(composerProps().value).toBe('') + expect(localStorage.getItem('mothership-drafts:v1')).not.toContain('Unsent launch follow-up') + } +) + +it('isolates saved drafts by user, organization, and conversation', async () => { + mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer) + await act(async () => renderHome()) + await act(async () => composerProps().onChange('Chat A follow-up')) + await act(async () => renderHome()) + expect(composerProps().value).toBe('') + await act(async () => renderHome()) + expect(composerProps().value).toBe('Chat A follow-up') + mocks.session.mockReturnValue({ data: { user: { id: 'other-user' } } }) + await act(async () => renderHome()) + expect(composerProps().value).toBe('') + mocks.session.mockReturnValue({ data: { user: { id: 'reader' } } }) + mocks.context.mockReturnValue({ + ...mocks.context(), + organization: { id: 'other-org', name: 'Other' }, + }) + await act(async () => renderHome()) + expect(composerProps().value).toBe('') +}) + +it('moves a follow-up draft to the newly resolved conversation before it is reopened', async () => { + mocks.renderer.mockImplementation(({ composer }: { composer: ReactNode }) => composer) + await act(async () => renderHome()) + await act(async () => composerProps().onSubmit('First question')) + await act(async () => composerProps().onChange('Follow-up typed before admission')) + mocks.chat.mockReturnValue({ ...mocks.chat(), resolvedChatId: 'adopted-chat' }) + await act(async () => renderHome()) + expect(composerProps().value).toBe('Follow-up typed before admission') + expect( + useMothershipDraftsStore.getState().drafts['reader:organization:organization-a:new'] + ).toBeUndefined() + await act(async () => + renderHome() + ) + expect(composerProps().value).toBe('Follow-up typed before admission') +}) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index a62a4bd146e..51a168ae8a9 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -40,6 +40,7 @@ import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId] import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' import type { ChatContext } from '@/stores/panel' @@ -61,7 +62,12 @@ export function OrganizationHome(props: OrganizationHomeProps) { if (!mothershipAvailable || (!canBuild && !searchAccess.memberScoped)) return null /** Preferences are browser-persisted and keyed by user; never paint a guessed mode first. */ if (!isClient || !session?.user?.id) return - return + return ( + + ) } function OrganizationHomeContent({ @@ -93,8 +99,6 @@ function OrganizationHomeContent({ : rememberedMode === 'assistant' && searchAccess.memberScoped ? 'assistant' : 'agent') - const [draft, setDraft] = useState('') - const [restoredContexts, setRestoredContexts] = useState([]) const controller = useResourcePanelController() const queryClient = useQueryClient() const chat = useChat({ organizationId: organization.id }, chatId, { @@ -103,6 +107,23 @@ function OrganizationHomeContent({ onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, }) + const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` + const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}` + const draft = useMothershipDraftsStore( + (state) => (state.drafts[draftKey] ?? state.drafts[initialDraftKey])?.text ?? '' + ) + const setDraft = useCallback( + (text: string, contexts?: ChatContext[]) => { + useMothershipDraftsStore.getState().setDraft(draftKey, { text, contexts }) + }, + [draftKey] + ) + const [restoredContexts, setRestoredContexts] = useState( + () => useMothershipDraftsStore.getState().drafts[draftKey]?.contexts ?? [] + ) + useEffect(() => { + useMothershipDraftsStore.getState().migrateDraft(initialDraftKey, draftKey) + }, [initialDraftKey, draftKey]) const hasChat = Boolean(chatId || chat.messages.length) const canSelectMode = !hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled) @@ -277,7 +298,7 @@ function OrganizationHomeContent({ ) const content = ( -
+
{hasChat ? ( + return } diff --git a/apps/sim/app/o/[organizationId]/search/search.test.tsx b/apps/sim/app/o/[organizationId]/search/search.test.tsx index 78f6da0998e..bfb703a03e1 100644 --- a/apps/sim/app/o/[organizationId]/search/search.test.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.test.tsx @@ -8,6 +8,7 @@ import type { ResourceScope } from '@/lib/core/resource-scope' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { useSpeechToText } from '@/hooks/use-speech-to-text' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' const mocks = vi.hoisted(() => ({ search: vi.fn(), @@ -77,6 +78,7 @@ let container: HTMLDivElement beforeEach(() => { vi.clearAllMocks() localStorage.clear() + useMothershipDraftsStore.setState({ drafts: {} }) mocks.mothershipAvailable = true vi.stubGlobal( 'ResizeObserver', @@ -138,7 +140,7 @@ async function render(searchParams = '') { await act(async () => root.render( - + ) ) @@ -194,7 +196,7 @@ describe('organization Search query navigation', () => { } ) - it('replaces the field draft and results when the committed URL query changes without remounting the page', async () => { + it('keeps committed URL navigation independent of the saved editable draft', async () => { await render('?q=Orion') expectVisibleQuery('Orion') await editDraft('Unsubmitted draft') @@ -204,7 +206,8 @@ describe('organization Search query navigation', () => { expect(container.textContent).not.toContain('Orion launch plan') await render('?q=Orion') - expectVisibleQuery('Orion') + expect(searchInput().value).toBe('Unsubmitted draft') + expect(container.querySelector('a[data-source-link]')?.textContent).toBe('Orion launch plan') expect(container.textContent).not.toContain('Vega launch plan') expect(mocks.urlUpdate).not.toHaveBeenCalled() }) @@ -266,3 +269,39 @@ it('hands document summaries to Search chat even when Build is the default', asy assistantSearch: { source: 'slack', documentIds: ['document-Orion'] }, }) }) + +it.each(['', '?q=Orion'])( + 'restores an unsent draft after remount and localStorage rehydration (%s)', + async (params) => { + await render(params) + await editDraft('Launch review follow-up') + await act(async () => root.unmount()) + const saved = localStorage.getItem('mothership-drafts:v1')! + useMothershipDraftsStore.setState({ drafts: {} }) + localStorage.setItem('mothership-drafts:v1', saved) + await useMothershipDraftsStore.persist.rehydrate() + root = createRoot(container) + await render(params) + expect(searchInput().value).toBe('Launch review follow-up') + expect(mocks.urlUpdate).not.toHaveBeenCalled() + } +) + +it('preserves a deliberately emptied Search input through remount', async () => { + await render('?q=Orion') + await editDraft('') + await act(async () => root.unmount()) + root = createRoot(container) + await render('?q=Orion') + expect(searchInput().value).toBe('') + expect(container.querySelector('a[data-source-link]')?.textContent).toBe('Orion launch plan') +}) + +it('restores the latest unsent draft when Search is reopened from navigation without a query', async () => { + await render('?q=Orion') + await editDraft('Unsent follow-up') + await render('') + expect(searchInput().value).toBe('Unsent follow-up') + expect(container.querySelector('a[data-source-link]')).toBeNull() + expect(mocks.urlUpdate).not.toHaveBeenCalled() +}) diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index f6ba6f4d61b..e29c6fa38ad 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { ComposerActionButton, toast } from '@sim/emcn' import { ArrowUp } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' @@ -18,17 +18,34 @@ import { SearchResultsView } from '@/app/o/[organizationId]/search/search-result import { MicButton } from '@/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button' import { MicrophonePermissionHelp } from '@/app/workspace/[workspaceId]/home/components/user-input/components/microphone-permission-help/microphone-permission-help' import { useVoiceInput } from '@/hooks/use-voice-input' +import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' + +interface OrganizationSearchProps { + userId: string +} interface SearchFieldProps { + userId: string initialValue: string onSubmit: (value: string) => void } /** Search commits a query on submit while retaining an independent editable draft. */ -function SearchField({ initialValue, onSubmit }: SearchFieldProps) { +function SearchField({ userId, initialValue, onSubmit }: SearchFieldProps) { const inputRef = useRef(null) const { organization } = useOrganizationContext() - const [value, setValue] = useState(initialValue) + const draftKey = `${userId}:organization:${organization.id}:search` + const draft = useMothershipDraftsStore((state) => state.drafts[draftKey]) + const value = + draft && (!initialValue || draft.searchQuery === initialValue) ? draft.text : initialValue + const setValue = (text: string) => { + useMothershipDraftsStore.getState().setDraft(draftKey, { text, searchQuery: initialValue }) + } + const submit = () => { + if (!value.trim()) return + useMothershipDraftsStore.getState().clearDraft(draftKey) + onSubmit(value) + } const voice = useVoiceInput({ organizationId: organization.id, getValue: () => value, @@ -44,7 +61,7 @@ function SearchField({ initialValue, onSubmit }: SearchFieldProps) { inputRef={inputRef} value={value} onChange={setValue} - onSubmit={() => onSubmit(value)} + onSubmit={submit} floating={!initialValue.trim()} voiceControl={ voice.isSupported && ( @@ -58,7 +75,7 @@ function SearchField({ initialValue, onSubmit }: SearchFieldProps) { submitControl={ onSubmit(value)} + onClick={submit} disabled={!canSubmit} aria-label='Search' active={canSubmit} @@ -76,13 +93,13 @@ function SearchField({ initialValue, onSubmit }: SearchFieldProps) { } /** Raw organization search remains independent of assistant conversations. */ -export function OrganizationSearch() { +export function OrganizationSearch({ userId }: OrganizationSearchProps) { const { searchAccess } = useOrganizationContext() if (!searchAccess.memberScoped) return null - return + return } -function OrganizationSearchContent() { +function OrganizationSearchContent({ userId }: OrganizationSearchProps) { const { organization, mothershipAvailable } = useOrganizationContext() const router = useRouter() const [{ q }, setParams] = useQueryStates(organizationSearchParsers, organizationSearchUrlKeys) @@ -103,7 +120,7 @@ function OrganizationSearchContent() { } return ( } + composer={} query={q.trim()} onSummarize={summarize} /> diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx index 65d26e21127..c4d7269ccf2 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/add-organization-source-modal.tsx @@ -10,6 +10,10 @@ import { ChipModalHeader, } from '@sim/emcn' import { Search } from '@sim/emcn/icons' +import { + GENERIC_SECRETS_SOURCE_TYPE, + GenericSecretSourceIcon, +} from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { @@ -54,7 +58,13 @@ export function AddOrganizationSourceModal({ } + icon={ + type === GENERIC_SECRETS_SOURCE_TYPE ? ( + + ) : ( + + ) + } title={meta.name} description={ !ready @@ -108,10 +118,10 @@ export function AddOrganizationSourceModal({ )} {compact ? ( - <> +
{feedback} {list} - +
) : ( {feedback} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx index 3b112304ad0..55e7c5af8f3 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/generic-secret-source.tsx @@ -16,10 +16,11 @@ import { } from '@sim/emcn' import { KeySquare } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' +import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { GenericSecretSource } from '@/lib/api/contracts/organization-secrets' import { organizationRoutes } from '@/lib/navigation/paths' import type { SecretSourceMode } from '@/lib/organization-secrets/validation' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { @@ -30,6 +31,19 @@ import { export const GENERIC_SECRETS_SOURCE_TYPE = 'generic-secrets' export const GENERIC_SECRETS_META = { name: 'Generic Secrets', icon: KeySquare } +/** Generic secrets belong to the organization rather than an external provider. */ +export function GenericSecretSourceIcon() { + const { organization } = useOrganizationContext() + return ( + + ) +} + interface GenericSecretSourceModalProps { organizationId: string source: GenericSecretSource | null @@ -113,7 +127,7 @@ export function GenericSecretSourceRow({ <> } + icon={} title='Generic Secrets' description={source.mode === 'organization' ? 'Organization' : 'Member'} trailing={ diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.test.tsx index a082d9ab44a..fabeac55859 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/live-search-settings.test.tsx @@ -28,7 +28,7 @@ vi.mock('next/navigation', async (importOriginal) => ({ })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ - organization: { id: 'org' }, + organization: { id: 'org', name: 'Example organization', logo: null }, viewer: { isAdmin: mocks.admin }, searchAccess: { memberScoped: true, sourceMirrored: true }, }), diff --git a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx index 090a9096847..f2efc1446eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/invite-modal/invite-modal.tsx @@ -41,7 +41,6 @@ const MEMBERSHIP_OPTIONS = [ type Membership = (typeof MEMBERSHIP_OPTIONS)[number]['value'] const MEMBERSHIP_HINTS: Partial> = { - admin: 'Joins your organization and can manage it. Adds a seat.', external: 'Access to the selected workspaces only — no seat. Only available for people already on a paid Sim plan.', } diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx index ec0b1e71185..25d86587e47 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.test.tsx @@ -6,7 +6,6 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PEEK_CLOSE_DELAY_MS as CLOSE_DELAY_MS, - PEEK_EXIT_DURATION_MS as EXIT_DURATION_MS, PEEK_OPEN_DELAY_MS as OPEN_DELAY_MS, PEEK_POINTER_SAMPLE_MS as POINTER_SAMPLE_MS, useSidebarPeek, @@ -273,12 +272,6 @@ describe('useSidebarPeek', () => { vi.advanceTimersByTime(CLOSE_DELAY_MS) }) expect(active.state().isPeekOpen).toBe(false) - // Stays mounted so the fade-out can play. - expect(active.state().isPeekActive).toBe(true) - - act(() => { - vi.advanceTimersByTime(EXIT_DURATION_MS) - }) expect(active.state().isPeekActive).toBe(false) }) @@ -334,41 +327,18 @@ describe('useSidebarPeek', () => { expect(active.state().isPeekActive).toBe(false) }) - it('drops an already-exiting card the instant a modal opens', () => { + it('reopens after the hover dwell when a card has retracted', () => { active = renderPeek(true) openPeek(active) movePointerTo(POINT.onContent) act(() => { vi.advanceTimersByTime(CLOSE_DELAY_MS) }) - expect(active.state().isPeekActive).toBe(true) - - active.setDismissed(true) - expect(active.state().isPeekActive).toBe(false) - }) - it('snaps a card that is animating out back open on re-hover', () => { - active = renderPeek(true) - openPeek(active) - movePointerTo(POINT.onContent) - act(() => { - vi.advanceTimersByTime(CLOSE_DELAY_MS) - }) - // Mid-exit: mounted but no longer open. - expect(active.state().isPeekActive).toBe(true) + act(() => active?.triggerEnter()) expect(active.state().isPeekOpen).toBe(false) - - // Re-hover late in the exit window; the pending exit timer must not win. - act(() => { - vi.advanceTimersByTime(EXIT_DURATION_MS - 20) - active?.triggerEnter() - }) - expect(active.state().isPeekOpen).toBe(true) - - act(() => { - vi.advanceTimersByTime(EXIT_DURATION_MS * 2) - }) + act(() => vi.advanceTimersByTime(OPEN_DELAY_MS)) expect(active.state().isPeekOpen).toBe(true) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts index d547b8263ed..9474676995d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/use-sidebar-peek.ts @@ -8,9 +8,6 @@ export const PEEK_OPEN_DELAY_MS = 90 /** Grace period after the pointer leaves, so a small overshoot doesn't retract the card. */ export const PEEK_CLOSE_DELAY_MS = 180 -/** How long the card stays mounted while its exit animation runs. */ -export const PEEK_EXIT_DURATION_MS = 150 - /** Floor between pointer hit-tests, so a fast drag doesn't measure rects every event. */ export const PEEK_POINTER_SAMPLE_MS = 16 @@ -43,7 +40,7 @@ const POPPER_SELECTOR = '[data-radix-popper-content-wrapper]' */ const OPEN_POPPER_SELECTOR = `${POPPER_SELECTOR} [data-state="open"]` -type PeekPhase = 'closed' | 'open' | 'exiting' +type PeekPhase = 'closed' | 'open' function clearTimer(ref: React.MutableRefObject | null>) { if (ref.current) { @@ -52,12 +49,6 @@ function clearTimer(ref: React.MutableRefObject | } } -function prefersReducedMotion(): boolean { - return ( - typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches - ) -} - /** * Whether a client point falls inside an element, padded outward by `pad`. * @@ -77,7 +68,7 @@ function containsPoint(element: Element | null, x: number, y: number, pad: numbe export interface SidebarPeekResult { /** Card is mounted as a floating overlay — drives positioning, chrome, and the expanded width. */ isPeekActive: boolean - /** Card is settled open. Goes false first on exit, so the exit animation can play. */ + /** Whether the floating card is visible. */ isPeekOpen: boolean /** Attach to the floating card so the pointer hit-test can recognise it. */ cardRef: React.RefObject @@ -96,9 +87,6 @@ export interface SidebarPeekResult { * floats the collapsed sidebar in over the content, and it retracts once the pointer * leaves. Clicking that same control still docks the sidebar for good. * - * The `exiting` phase keeps the card mounted for {@link PEEK_EXIT_DURATION_MS} so its - * exit animation can play; unmounting immediately would snap it away mid-animation. - * * Retraction is detected from a document-level `pointermove` hit-test rather than * `mouseleave`, because the menus and tooltips the sidebar opens live in body * portals. A `mouseleave`-driven peek would retract the instant the pointer crossed @@ -114,28 +102,18 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek const triggerRef = useRef(null) const openTimerRef = useRef | null>(null) const closeTimerRef = useRef | null>(null) - const exitTimerRef = useRef | null>(null) const [phase, setPhase] = useState('closed') const open = useCallback(() => { clearTimer(closeTimerRef) - clearTimer(exitTimerRef) setPhase('open') }, []) const close = useCallback(() => { clearTimer(openTimerRef) clearTimer(closeTimerRef) - clearTimer(exitTimerRef) - setPhase((current) => (current === 'open' ? 'exiting' : current)) - exitTimerRef.current = setTimeout( - () => { - exitTimerRef.current = null - setPhase('closed') - }, - prefersReducedMotion() ? 0 : PEEK_EXIT_DURATION_MS - ) + setPhase('closed') }, []) const onTriggerEnter = useCallback(() => { @@ -145,37 +123,21 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek if (!enabled || dismissed) return clearTimer(closeTimerRef) clearTimer(openTimerRef) - // Still on screen and animating out: snap it back instead of waiting out another - // dwell, which the pending exit timer would win — unmounting the card and then - // re-mounting it, a visible flicker with the pointer never leaving the toggle. - if (phase === 'exiting') { - open() - return - } openTimerRef.current = setTimeout(() => { openTimerRef.current = null open() }, PEEK_OPEN_DELAY_MS) - }, [dismissed, enabled, open, phase]) + }, [dismissed, enabled, open]) const onTriggerLeave = useCallback(() => { clearTimer(openTimerRef) }, []) - /** - * Drop the card outright — no exit animation — the moment the peek stops being - * available (⌘B, fullscreen) or a modal takes the screen. - * - * Unconditional rather than gated on the current phase, because every phase needs - * clearing: a pending dwell would otherwise fire and mount the card over the modal, - * and an in-flight exit would keep animating on top of it. Instant is also right - * visually — the modal's own scrim covers the card's position on the same frame. - */ + /** Clear pending hover work when the peek is unavailable or a modal owns the screen. */ useEffect(() => { if (enabled && !dismissed) return clearTimer(openTimerRef) clearTimer(closeTimerRef) - clearTimer(exitTimerRef) setPhase('closed') }, [dismissed, enabled]) @@ -242,14 +204,15 @@ export function useSidebarPeek(enabled: boolean, dismissed = false): SidebarPeek () => () => { clearTimer(openTimerRef) clearTimer(closeTimerRef) - clearTimer(exitTimerRef) }, [] ) + const isPeekVisible = phase === 'open' && enabled && !dismissed + return { - isPeekActive: phase !== 'closed' && enabled, - isPeekOpen: phase === 'open' && enabled, + isPeekActive: isPeekVisible, + isPeekOpen: isPeekVisible, cardRef, triggerRef, onTriggerEnter, diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 94125db0f25..29ff3371224 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -40,26 +40,6 @@ const FULLSCREEN_SUFFIXES = ['/upgrade'] as const const PEEK_CARD_CHROME = 'absolute top-[var(--desktop-title-bar-height)] left-2 z-[var(--z-modal)] flex max-h-[calc(100%-var(--desktop-title-bar-height)-8px)] w-auto flex-col origin-top-left rounded-lg border border-[var(--border)]' -/** - * Peek card enter/exit — the popper idiom rather than a slide, since the card is - * anchored to the title-bar toggle and grows out of that corner exactly as emcn's - * Radix surfaces do (`dropdown-menu.tsx`: `fade-in-0 zoom-in-95`). - * - * Animations rather than transitions: an animation runs from mount, so the card needs - * no hidden "from" frame and no `requestAnimationFrame` to step into — rAF is throttled - * in an unfocused window, which would strand the card mounted-but-invisible. - * - * `duration-150` must match {@link PEEK_EXIT_DURATION_MS}. - */ -const PEEK_CARD_ENTER = cn( - PEEK_CARD_CHROME, - 'animate-in fade-in-0 zoom-in-95 duration-150 ease-out motion-reduce:animate-none' -) -const PEEK_CARD_EXIT = cn( - PEEK_CARD_CHROME, - 'pointer-events-none animate-out fade-out-0 zoom-out-95 fill-mode-forwards duration-150 ease-out motion-reduce:animate-none' -) - /** * The divider between the rail and the content pane, dropped when there is no rail * beside it: collapsed to nothing in the desktop shell, where the pane sits hard @@ -160,9 +140,7 @@ function isFullscreenPath(pathname: string | null): boolean { * zero width, revealing the route content. Because this component lives in the * layout it persists across navigations, so the rail never re-mounts. * - * The docked rail and content pane share one width transition. Drag-resizing, - * hydration, and reduced motion bypass it; the floating peek retains its own - * enter/exit animation. + * The docked rail and floating peek change immediately without layout animations. * * Because the chrome observes every pathname transition, it records the page a * fullscreen route was launched from into {@link useFullscreenOriginStore}. The @@ -221,8 +199,10 @@ export function WorkspaceChrome({ * and native fullscreen falls back to that same rail. */ const peekEnabled = isCollapsed && !isFullscreen && titleBarMode === 'inset' - const { isPeekActive, isPeekOpen, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } = - useSidebarPeek(peekEnabled, isSearchModalOpen) + const { isPeekActive, cardRef, triggerRef, onTriggerEnter, onTriggerLeave } = useSidebarPeek( + peekEnabled, + isSearchModalOpen + ) // Hydrate the persisted width before paint (collapse comes from the cookie/prop). useLayoutEffect(() => { @@ -330,22 +310,12 @@ export function WorkspaceChrome({