From 04147f7401a513033fce82258b7ee8a947acd70e Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:13:20 -0700 Subject: [PATCH 01/29] fix(chat): stop losing sends aborted during mount-settling --- .../home/hooks/use-chat.mount-send.test.tsx | 183 ++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 41 +++- 2 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx new file mode 100644 index 00000000000..e12d0320328 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -0,0 +1,183 @@ +/** + * @vitest-environment jsdom + * + * Regression tests for the mount-settling send loss: a send started on a + * fresh chat surface used to be silently dropped when React ran the unmount + * cleanup mid-flight (a Suspense hide/reveal cycles every effect shortly + * after Home mounts), aborting the fetch before it dispatched. The fix routes + * idle sends through the durable message queue and restores the queued entry + * when the cleanup abort strikes before the server received the request. + */ +import { act, type ReactNode } from 'react' +import { sleep } from '@sim/utils/helpers' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRequestJson, navigationMocks } = vi.hoisted(() => ({ + mockRequestJson: vi.fn(), + navigationMocks: { + usePathname: vi.fn(() => '/workspace/ws-1/home'), + useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() })), + useSearchParams: vi.fn(() => new URLSearchParams()), + }, +})) + +vi.mock('next/navigation', () => navigationMocks) + +vi.mock('@/lib/api/client/request', async (importOriginal) => ({ + ...(await importOriginal()), + requestJson: mockRequestJson, +})) + +import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import { useMothershipQueueStore } from '@/stores/mothership-queue/store' + +interface NetworkState { + /** How the chat POST behaves for the next call. */ + postBehavior: 'hang' | 'accept' + postCalls: number +} + +const state: NetworkState = { postBehavior: 'hang', postCalls: 0 } + +/** An SSE response whose stream ends immediately without a terminal event. */ +function emptySseResponse(): Response { + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }) +} + +function abortError(): Error { + const error = new Error('Aborted') + error.name = 'AbortError' + return error +} + +async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = String(input instanceof Request ? input.url : input) + + if (url.includes('/api/mothership/chat') && init?.method === 'POST') { + state.postCalls++ + if (state.postBehavior === 'accept') return emptySseResponse() + return new Promise((_, reject) => { + const signal = init?.signal + if (!signal) return + if (signal.aborted) { + reject(abortError()) + return + } + signal.addEventListener('abort', () => reject(abortError()), { once: true }) + }) + } + + return new Response(JSON.stringify({ error: 'not found' }), { status: 404 }) +} + +const mountedRoots: Root[] = [] +let queryClient: QueryClient + +function renderUseChat(): { + getResult: () => ReturnType + unmount: () => void +} { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const container = document.createElement('div') + const root = createRoot(container) + mountedRoots.push(root) + let result: ReturnType | undefined + + function Probe() { + result = useChat('ws-1', undefined) + return null + } + + act(() => { + root.render( + {() as ReactNode} + ) + }) + + return { + getResult: () => { + if (result === undefined) throw new Error('Hook result is not ready') + return result + }, + unmount: () => act(() => root.unmount()), + } +} + +/** Every queued message across all chat keys, flattened. */ +function allQueuedMessages() { + return Object.values(useMothershipQueueStore.getState().queues).flat() +} + +async function waitFor(predicate: () => boolean, budgetMs = 2000): Promise { + const deadline = Date.now() + budgetMs + while (!predicate()) { + if (Date.now() > deadline) throw new Error('waitFor timed out') + await act(async () => { + await sleep(10) + }) + } +} + +describe('useChat mount-settling send recovery', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchStub) + state.postBehavior = 'hang' + state.postCalls = 0 + mockRequestJson.mockResolvedValue({ chats: [] }) + useMothershipQueueStore.setState({ queues: {}, editing: {} }) + window.sessionStorage.clear() + }) + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } + queryClient?.clear() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('restores a send the unmount cleanup aborted before the server received it', async () => { + const { getResult, unmount } = renderUseChat() + + await act(async () => { + void getResult().sendMessage('hello from the palette') + }) + await waitFor(() => state.postCalls === 1) + + // The dispatch claimed the queue head when the optimistic send applied. + expect(allQueuedMessages()).toHaveLength(0) + + // The cleanup abort (same code path a Suspense hide/reveal runs during + // mount-settling) fires while the POST is still awaiting the server. + unmount() + await waitFor(() => allQueuedMessages().length === 1) + + expect(allQueuedMessages()[0].content).toBe('hello from the palette') + }) + + it('does not re-queue a send the server already received', async () => { + state.postBehavior = 'accept' + const { getResult, unmount } = renderUseChat() + + await act(async () => { + void getResult().sendMessage('already accepted') + }) + await waitFor(() => state.postCalls === 1) + + unmount() + await act(async () => { + await sleep(50) + }) + + expect(allQueuedMessages()).toHaveLength(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index e3ce3327301..ee3f8f6f961 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1343,6 +1343,12 @@ export function useChat( const queueDispatchActionsRef = useRef([]) const queueDispatchTaskRef = useRef | null>(null) const queueDispatchEpochRef = useRef(0) + /** + * Set when the in-flight dispatch was killed by the unmount cleanup before + * reaching the server. Lets the restore path re-queue the message across the + * epoch bump that same cleanup performs. + */ + const restorableCleanupAbortRef = useRef(false) const queueDispatchLoopRef = useRef<() => Promise>(async () => {}) const enqueueQueueDispatchRef = useRef<(action: QueueDispatchActionInput) => Promise>( async () => {} @@ -3465,6 +3471,8 @@ export function useChat( : undefined let consumedByTranscript = false + let sendReachedServer = false + let sendAbortSignal: AbortSignal | null = null setError(null) setTransportStreaming() @@ -3697,6 +3705,7 @@ export function useChat( } const abortController = new AbortController() abortControllerRef.current = abortController + sendAbortSignal = abortController.signal const resourceAttachments = buildResourceAttachments( resourcesRef.current, @@ -3725,6 +3734,7 @@ export function useChat( }), signal: abortController.signal, }) + sendReachedServer = true // Capture for propagation on side-channel calls + non-React // tool-completion callbacks (via trace-context singleton). @@ -3823,7 +3833,19 @@ export function useChat( } } } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return consumedByTranscript + if (err instanceof Error && err.name === 'AbortError') { + if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { + /* The mount-settling effect cycle (Suspense hide/reveal) ran the + unmount cleanup while this send was still pre-dispatch. Nothing + reached the server, so the send is fully recoverable: withdraw + the optimistic pair and report not-consumed so the queued entry + is restored and re-dispatched when effects re-run. */ + rollbackOptimisticSend() + restorableCleanupAbortRef.current = true + return false + } + return consumedByTranscript + } if (isStreamSchemaValidationError(err)) { setError(err.message) if (gen !== undefined && streamGenRef.current === gen) { @@ -3912,9 +3934,15 @@ export function useChat( return } - await startSendMessage(message, fileAttachments, contexts) + /* Even an idle-path send goes through the durable queue: a direct + startSendMessage has no backing entry, so the cleanup abort that runs + when a Suspense hide/reveal cycles effects during mount-settling would + silently drop it. The dispatch loop claims the head immediately, so + the message never renders as queued. */ + queueStore.enqueue(activeChatKey, createQueuedMessage(message, fileAttachments, contexts)) + void enqueueQueueDispatchRef.current({ type: 'send_head' }) }, - [workspaceId, startSendMessage, createQueuedMessage] + [workspaceId, createQueuedMessage] ) useEffect(() => { if (typeof window === 'undefined') return @@ -4461,7 +4489,10 @@ export function useChat( clearQueuedSendHandoffState(msg.id) } clearQueuedSendHandoffClaim(msg.id) - if (!removedFromQueue || options.epoch !== queueDispatchEpochRef.current) { + if (!removedFromQueue) { + return + } + if (options.epoch !== queueDispatchEpochRef.current && !restorableCleanupAbortRef.current) { return } // If the user explicitly removed this message during dispatch, honor @@ -4487,6 +4518,7 @@ export function useChat( // between dispatch scheduling and this send. const liveMsg = queueAtSend[currentIndex] activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff + restorableCleanupAbortRef.current = false const consumed = await startSendMessage( liveMsg.content, liveMsg.fileAttachments, @@ -4502,6 +4534,7 @@ export function useChat( } catch { restoreQueuedMessage(activeQueuedSendHandoff) } finally { + restorableCleanupAbortRef.current = false setDispatchingHeadId((current) => (current === msg.id ? null : current)) queuedMessageDispatchIdsRef.current.delete(msg.id) userRemovedDuringDispatchRef.current.delete(msg.id) From b840a3a15335b877c4cb4d8a36d4095baec9eb65 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:30:14 -0700 Subject: [PATCH 02/29] fix(chat): detect aborts by signal state, not error identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch rejects with the RAW abort reason when its signal carries one — abort('unmount:client_cleanup') surfaces as a plain string, so every err.name === 'AbortError' check missed it and the restore path never ran (verified live). The test stub now rejects with the raw reason like real fetch, which turns this gap red. --- .../home/hooks/use-chat.mount-send.test.tsx | 12 ++++-------- .../workspace/[workspaceId]/home/hooks/use-chat.ts | 7 ++++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index e12d0320328..2b3bef325e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -51,12 +51,6 @@ function emptySseResponse(): Response { return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }) } -function abortError(): Error { - const error = new Error('Aborted') - error.name = 'AbortError' - return error -} - async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise { const url = String(input instanceof Request ? input.url : input) @@ -66,11 +60,13 @@ async function fetchStub(input: RequestInfo | URL, init?: RequestInit): Promise< return new Promise((_, reject) => { const signal = init?.signal if (!signal) return + // Real fetch rejects with the RAW abort reason (a string here), not an + // AbortError — the regression this suite guards depends on that shape. if (signal.aborted) { - reject(abortError()) + reject(signal.reason) return } - signal.addEventListener('abort', () => reject(abortError()), { once: true }) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) }) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index ee3f8f6f961..8124a1369da 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -3833,7 +3833,12 @@ export function useChat( } } } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { + /* fetch rejects with the RAW abort reason (here a plain string) when + its signal was aborted with abort(reason) — an `err.name` check alone + misses those, so abort detection also consults the signal itself. */ + const sendWasAborted = + (err instanceof Error && err.name === 'AbortError') || sendAbortSignal?.aborted === true + if (sendWasAborted) { if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { /* The mount-settling effect cycle (Suspense hide/reveal) ran the unmount cleanup while this send was still pre-dispatch. Nothing From cd23af8b994b284a0b10484c8393d9502fc85a0a Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:36:28 -0700 Subject: [PATCH 03/29] fix(chat): hand an aborted chatless send to the next mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mount-settling cycle is a full remount — the pending chat key is regenerated per instance, so restoring the aborted send into the dead instance's queue orphaned it (verified live). A chatless send now re-persists as a one-shot MothershipHandoffStorage handoff the next mount's consumer re-sends; chat-bound sends keep the queue restore. --- .../home/hooks/use-chat.mount-send.test.tsx | 17 +++++++++---- .../[workspaceId]/home/hooks/use-chat.ts | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 2b3bef325e8..44c71a8c012 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -30,6 +30,7 @@ vi.mock('@/lib/api/client/request', async (importOriginal) => ({ requestJson: mockRequestJson, })) +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' @@ -130,6 +131,7 @@ describe('useChat mount-settling send recovery', () => { mockRequestJson.mockResolvedValue({ chats: [] }) useMothershipQueueStore.setState({ queues: {}, editing: {} }) window.sessionStorage.clear() + window.localStorage.clear() }) afterEach(() => { @@ -141,7 +143,7 @@ describe('useChat mount-settling send recovery', () => { vi.clearAllMocks() }) - it('restores a send the unmount cleanup aborted before the server received it', async () => { + it('re-persists an aborted chatless send as a handoff for the next mount', async () => { const { getResult, unmount } = renderUseChat() await act(async () => { @@ -152,12 +154,16 @@ describe('useChat mount-settling send recovery', () => { // The dispatch claimed the queue head when the optimistic send applied. expect(allQueuedMessages()).toHaveLength(0) - // The cleanup abort (same code path a Suspense hide/reveal runs during - // mount-settling) fires while the POST is still awaiting the server. + // The cleanup abort (the same code path the mount-settling remount runs) + // fires while the POST is still awaiting the server. A chatless surface + // regenerates its queue key per mount, so recovery re-persists the send + // as a one-shot handoff for the next mount's consumer instead of + // restoring the dead instance's queue. unmount() - await waitFor(() => allQueuedMessages().length === 1) + await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) - expect(allQueuedMessages()[0].content).toBe('hello from the palette') + expect(allQueuedMessages()).toHaveLength(0) + expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('hello from the palette') }) it('does not re-queue a send the server already received', async () => { @@ -175,5 +181,6 @@ describe('useChat mount-settling send recovery', () => { }) expect(allQueuedMessages()).toHaveLength(0) + expect(MothershipHandoffStorage.consume('ws-1')).toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8124a1369da..0720ae93db8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -82,6 +82,7 @@ import { executeTerminalToolOnClient } from '@/lib/copilot/tools/client/terminal import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { readSSELines } from '@/lib/core/utils/sse' import { getDesktopBridge, getDesktopChatCapabilities } from '@/lib/desktop' import { @@ -4505,6 +4506,27 @@ export function useChat( if (userRemovedDuringDispatchRef.current.delete(msg.id)) { return } + /* A pending (chatless) surface regenerates its chat key per mount, and + the cleanup that aborted this send belongs to a full remount — a + queue restore would orphan the message under the dead instance's + key. Re-persist it as a one-shot handoff instead: the next mount's + consumer re-sends it. Chat-bound sends keep the queue restore (their + key is the stable chat id). Attachment payloads exceed what the + handoff carries, so they fall back to the queue restore. */ + if ( + restorableCleanupAbortRef.current && + dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && + !msg.fileAttachments?.length + ) { + MothershipHandoffStorage.store( + { + message: msg.content, + ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + }, + workspaceId + ) + return + } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } @@ -4545,7 +4567,7 @@ export function useChat( userRemovedDuringDispatchRef.current.delete(msg.id) } }, - [startSendMessage] + [startSendMessage, workspaceId] ) const runQueueDispatchLoop = useCallback(async () => { From ed4ea7cc3dbb726f64440c536b8e2b426b1338bb Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:39:02 -0700 Subject: [PATCH 04/29] fix(chat): deliver an aborted chatless send to the live replacement surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settling remount's consumer checks handoff storage before the restore microtask re-persists it, so the stored handoff sat unread until a navigation. The replacement surface's send listener IS registered by restore time — deliver the message directly through the claimable send event, keeping the stored handoff as the no-surface fallback. --- .../home/hooks/use-chat.mount-send.test.tsx | 25 +++++++++++++++++++ .../[workspaceId]/home/hooks/use-chat.ts | 21 ++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 44c71a8c012..06e83b45559 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -143,6 +143,31 @@ describe('useChat mount-settling send recovery', () => { vi.clearAllMocks() }) + it('delivers an aborted chatless send directly to a live replacement surface', async () => { + const received: string[] = [] + const claim = (event: Event) => { + received.push((event as CustomEvent<{ message: string }>).detail.message) + event.preventDefault() + } + window.addEventListener('mothership-send-message', claim) + + try { + const { getResult, unmount } = renderUseChat() + await act(async () => { + void getResult().sendMessage('hello from the palette') + }) + await waitFor(() => state.postCalls === 1) + + unmount() + await waitFor(() => received.length === 1) + + expect(received).toEqual(['hello from the palette']) + expect(window.localStorage.getItem('sim_mothership_handoff')).toBeNull() + } finally { + window.removeEventListener('mothership-send-message', claim) + } + }) + it('re-persists an aborted chatless send as a handoff for the next mount', async () => { const { getResult, unmount } = renderUseChat() diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0720ae93db8..a3fe0767a90 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -92,6 +92,7 @@ import { migrateDesktopChatScopes, PENDING_CHAT_KEY_PREFIX, } from '@/lib/desktop/chat-scope' +import { sendMothershipMessage } from '@/lib/mothership/events' import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' @@ -4518,13 +4519,19 @@ export function useChat( dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && !msg.fileAttachments?.length ) { - MothershipHandoffStorage.store( - { - message: msg.content, - ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), - }, - workspaceId - ) + /* The settling remount has already run its mount effects by the time + this microtask executes, so the replacement surface's send listener + is live — deliver directly. The stored-handoff fallback covers a + real navigation away, where the next mount's consumer picks it up. */ + if (!sendMothershipMessage(msg.content, msg.contexts)) { + MothershipHandoffStorage.store( + { + message: msg.content, + ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + }, + workspaceId + ) + } return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) From d078e823dacb3f1eb423099539e9f6513097c8a8 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:55:58 -0700 Subject: [PATCH 05/29] refactor(chat): thread the recoverable-abort outcome through the send result Replaces the restorableCleanupAbortRef reset choreography with a widened startSendMessage return ('recoverable_cleanup_abort'), so the restore decision is ordinary data flow and the second caller cannot leave a stale flag behind. --- .../[workspaceId]/home/hooks/use-chat.ts | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a3fe0767a90..a46f5c64300 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -1345,12 +1345,6 @@ export function useChat( const queueDispatchActionsRef = useRef([]) const queueDispatchTaskRef = useRef | null>(null) const queueDispatchEpochRef = useRef(0) - /** - * Set when the in-flight dispatch was killed by the unmount cleanup before - * reaching the server. Lets the restore path re-queue the message across the - * epoch bump that same cleanup performs. - */ - const restorableCleanupAbortRef = useRef(false) const queueDispatchLoopRef = useRef<() => Promise>(async () => {}) const enqueueQueueDispatchRef = useRef<(action: QueueDispatchActionInput) => Promise>( async () => {} @@ -3842,14 +3836,12 @@ export function useChat( (err instanceof Error && err.name === 'AbortError') || sendAbortSignal?.aborted === true if (sendWasAborted) { if (sendAbortSignal?.reason === 'unmount:client_cleanup' && !sendReachedServer) { - /* The mount-settling effect cycle (Suspense hide/reveal) ran the - unmount cleanup while this send was still pre-dispatch. Nothing - reached the server, so the send is fully recoverable: withdraw - the optimistic pair and report not-consumed so the queued entry - is restored and re-dispatched when effects re-run. */ + /* The mount-settling remount ran the unmount cleanup while this + send was still pre-dispatch. Nothing reached the server, so the + send is fully recoverable: withdraw the optimistic pair and + report the distinct outcome so the dispatcher redelivers it. */ rollbackOptimisticSend() - restorableCleanupAbortRef.current = true - return false + return 'recoverable_cleanup_abort' } return consumedByTranscript } @@ -4491,7 +4483,10 @@ export function useChat( useMothershipQueueStore.getState().remove(dispatchChatKey, msg.id) } - const restoreQueuedMessage = (handoff?: QueuedSendHandoffSeed) => { + const restoreQueuedMessage = ( + handoff?: QueuedSendHandoffSeed, + recoverableCleanupAbort = false + ) => { if (!handoff) { clearQueuedSendHandoffState(msg.id) } @@ -4499,7 +4494,7 @@ export function useChat( if (!removedFromQueue) { return } - if (options.epoch !== queueDispatchEpochRef.current && !restorableCleanupAbortRef.current) { + if (options.epoch !== queueDispatchEpochRef.current && !recoverableCleanupAbort) { return } // If the user explicitly removed this message during dispatch, honor @@ -4515,7 +4510,7 @@ export function useChat( key is the stable chat id). Attachment payloads exceed what the handoff carries, so they fall back to the queue restore. */ if ( - restorableCleanupAbortRef.current && + recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && !msg.fileAttachments?.length ) { @@ -4552,8 +4547,7 @@ export function useChat( // between dispatch scheduling and this send. const liveMsg = queueAtSend[currentIndex] activeQueuedSendHandoff = options.queuedSendHandoff ?? liveMsg.queuedSendHandoff - restorableCleanupAbortRef.current = false - const consumed = await startSendMessage( + const sendResult = await startSendMessage( liveMsg.content, liveMsg.fileAttachments, liveMsg.contexts, @@ -4562,13 +4556,12 @@ export function useChat( activeQueuedSendHandoff ) - if (!consumed) { - restoreQueuedMessage(activeQueuedSendHandoff) + if (sendResult !== true) { + restoreQueuedMessage(activeQueuedSendHandoff, sendResult === 'recoverable_cleanup_abort') } } catch { restoreQueuedMessage(activeQueuedSendHandoff) } finally { - restorableCleanupAbortRef.current = false setDispatchingHeadId((current) => (current === msg.id ? null : current)) queuedMessageDispatchIdsRef.current.delete(msg.id) userRemovedDuringDispatchRef.current.delete(msg.id) From 81716ffd131e50de4686c09b338bf4d9b28ccda1 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:34:10 -0700 Subject: [PATCH 06/29] fix(chat): carry attachments through the cross-mount send handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recoverable-abort delivery excluded attachment-bearing sends, so they restored under the dead instance's pending key and were silently lost. The claimable send event now carries fileAttachments end to end (dispatcher, home listener, restore path); only the storage fallback — whose shape cannot hold attachments — still queue-restores them. --- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../home/hooks/use-chat.mount-send.test.tsx | 17 ++++++++--- .../[workspaceId]/home/hooks/use-chat.ts | 28 +++++++++---------- apps/sim/lib/mothership/events.ts | 10 ++++++- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 492b5659464..d6ed5908650 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -341,7 +341,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const detail = (e as CustomEvent).detail if (!detail?.message) return e.preventDefault() - sendMessage(detail.message, undefined, detail.contexts) + sendMessage(detail.message, detail.fileAttachments, detail.contexts) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index 06e83b45559..d950b238ac1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -144,9 +144,17 @@ describe('useChat mount-settling send recovery', () => { }) it('delivers an aborted chatless send directly to a live replacement surface', async () => { - const received: string[] = [] + const attachment = { + id: 'file-1', + key: 'uploads/file-1', + filename: 'notes.txt', + media_type: 'text/plain', + size: 12, + } + const received: Array<{ message: string; fileAttachments?: unknown[] }> = [] const claim = (event: Event) => { - received.push((event as CustomEvent<{ message: string }>).detail.message) + const detail = (event as CustomEvent<{ message: string; fileAttachments?: unknown[] }>).detail + received.push(detail) event.preventDefault() } window.addEventListener('mothership-send-message', claim) @@ -154,14 +162,15 @@ describe('useChat mount-settling send recovery', () => { try { const { getResult, unmount } = renderUseChat() await act(async () => { - void getResult().sendMessage('hello from the palette') + void getResult().sendMessage('hello from the palette', [attachment]) }) await waitFor(() => state.postCalls === 1) unmount() await waitFor(() => received.length === 1) - expect(received).toEqual(['hello from the palette']) + expect(received[0].message).toBe('hello from the palette') + expect(received[0].fileAttachments).toEqual([attachment]) expect(window.localStorage.getItem('sim_mothership_handoff')).toBeNull() } finally { window.removeEventListener('mothership-send-message', claim) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index a46f5c64300..f6a06efe61b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4505,20 +4505,18 @@ export function useChat( /* A pending (chatless) surface regenerates its chat key per mount, and the cleanup that aborted this send belongs to a full remount — a queue restore would orphan the message under the dead instance's - key. Re-persist it as a one-shot handoff instead: the next mount's - consumer re-sends it. Chat-bound sends keep the queue restore (their - key is the stable chat id). Attachment payloads exceed what the - handoff carries, so they fall back to the queue restore. */ - if ( - recoverableCleanupAbort && - dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX) && - !msg.fileAttachments?.length - ) { - /* The settling remount has already run its mount effects by the time - this microtask executes, so the replacement surface's send listener - is live — deliver directly. The stored-handoff fallback covers a - real navigation away, where the next mount's consumer picks it up. */ - if (!sendMothershipMessage(msg.content, msg.contexts)) { + key. Deliver to the replacement surface instead: its send listener + is live by the time this microtask executes, and the event carries + attachments. When nothing claims it (a real navigation away), a + one-shot handoff covers attachment-less sends for the next mount; + attachment payloads exceed what the handoff carries and fall back to + the queue restore. Chat-bound sends always keep the queue restore + (their key is the stable chat id). */ + if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { + if (sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { + return + } + if (!msg.fileAttachments?.length) { MothershipHandoffStorage.store( { message: msg.content, @@ -4526,8 +4524,8 @@ export function useChat( }, workspaceId ) + return } - return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } diff --git a/apps/sim/lib/mothership/events.ts b/apps/sim/lib/mothership/events.ts index d376ee0080f..12679bf3d71 100644 --- a/apps/sim/lib/mothership/events.ts +++ b/apps/sim/lib/mothership/events.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import type { ChatContext } from '@/stores/panel' const logger = createLogger('MothershipEvents') @@ -24,6 +25,8 @@ export interface MothershipSendMessageDetail { message: string /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ contexts?: ChatContext[] + /** Already-uploaded attachments riding along with the message. */ + fileAttachments?: FileAttachmentForApi[] } /** @@ -35,7 +38,11 @@ export interface MothershipSendMessageDetail { * was listening — callers that can fall back (e.g. cross-route navigation) use * this to decide whether to persist a handoff instead. */ -export function sendMothershipMessage(message: string, contexts?: ChatContext[]): boolean { +export function sendMothershipMessage( + message: string, + contexts?: ChatContext[], + fileAttachments?: FileAttachmentForApi[] +): boolean { const trimmed = message.trim() if (!trimmed) { logger.warn('sendMothershipMessage called with empty message') @@ -44,6 +51,7 @@ export function sendMothershipMessage(message: string, contexts?: ChatContext[]) const consumed = dispatchClaimable(MOTHERSHIP_SEND_MESSAGE_EVENT, { message: trimmed, contexts, + fileAttachments, }) logger.info('Dispatched mothership message event', { messageLength: trimmed.length, consumed }) return consumed From 45357cb851f5cef133d79b71cc2516758ac72c60 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:42:08 -0700 Subject: [PATCH 07/29] fix(panel): forward event attachments to the copilot send --- .../[workspaceId]/w/[workflowId]/components/panel/panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 57a97042c96..510d62579f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -489,7 +489,7 @@ export const Panel = memo(function Panel() { if (!detail?.message) return e.preventDefault() setActiveTab('copilot') - copilotSendMessage(detail.message, undefined, detail.contexts) + copilotSendMessage(detail.message, detail.fileAttachments, detail.contexts) } window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) return () => window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handler) From 3e677696cda920e000ef7c404fcbaf32660b8a52 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:48:57 -0700 Subject: [PATCH 08/29] fix(chat): carry attachments through the stored handoff lane too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unclaimed-event fallback excluded attachment sends and restored them under the disposed mount's pending key. The persisted handoff now carries fileAttachments (they are plain references to already-uploaded files), the home consumer forwards them, and the recovery branch always hands off — no stranded lane remains. --- .../app/workspace/[workspaceId]/home/home.tsx | 2 +- .../home/hooks/use-chat.mount-send.test.tsx | 13 +++++++++++-- .../[workspaceId]/home/hooks/use-chat.ts | 18 +++++++----------- apps/sim/lib/core/utils/browser-storage.ts | 12 +++++++++++- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index d6ed5908650..1e6ef10d6ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -370,7 +370,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const handoff = MothershipHandoffStorage.consume(workspaceId) if (!handoff) return if (handoff.message) { - sendMessage(handoff.message, undefined, handoff.contexts) + sendMessage(handoff.message, handoff.fileAttachments, handoff.contexts) return } const contexts = handoff.contexts ?? [] diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx index d950b238ac1..c0aa8e76297 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.mount-send.test.tsx @@ -178,10 +178,17 @@ describe('useChat mount-settling send recovery', () => { }) it('re-persists an aborted chatless send as a handoff for the next mount', async () => { + const attachment = { + id: 'file-2', + key: 'uploads/file-2', + filename: 'report.pdf', + media_type: 'application/pdf', + size: 99, + } const { getResult, unmount } = renderUseChat() await act(async () => { - void getResult().sendMessage('hello from the palette') + void getResult().sendMessage('hello from the palette', [attachment]) }) await waitFor(() => state.postCalls === 1) @@ -197,7 +204,9 @@ describe('useChat mount-settling send recovery', () => { await waitFor(() => window.localStorage.getItem('sim_mothership_handoff') !== null) expect(allQueuedMessages()).toHaveLength(0) - expect(MothershipHandoffStorage.consume('ws-1')?.message).toBe('hello from the palette') + const handoff = MothershipHandoffStorage.consume('ws-1') + expect(handoff?.message).toBe('hello from the palette') + expect(handoff?.fileAttachments).toEqual([attachment]) }) it('does not re-queue a send the server already received', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index f6a06efe61b..5f86ef8409f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -4506,26 +4506,22 @@ export function useChat( the cleanup that aborted this send belongs to a full remount — a queue restore would orphan the message under the dead instance's key. Deliver to the replacement surface instead: its send listener - is live by the time this microtask executes, and the event carries - attachments. When nothing claims it (a real navigation away), a - one-shot handoff covers attachment-less sends for the next mount; - attachment payloads exceed what the handoff carries and fall back to - the queue restore. Chat-bound sends always keep the queue restore - (their key is the stable chat id). */ + is live by the time this microtask executes. When nothing claims the + event (a real navigation away), a one-shot handoff covers the next + mount; both lanes carry attachments. Chat-bound sends keep the queue + restore — their key is the stable chat id. */ if (recoverableCleanupAbort && dispatchChatKey.startsWith(PENDING_CHAT_KEY_PREFIX)) { - if (sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { - return - } - if (!msg.fileAttachments?.length) { + if (!sendMothershipMessage(msg.content, msg.contexts, msg.fileAttachments)) { MothershipHandoffStorage.store( { message: msg.content, ...(msg.contexts?.length ? { contexts: msg.contexts } : {}), + ...(msg.fileAttachments?.length ? { fileAttachments: msg.fileAttachments } : {}), }, workspaceId ) - return } + return } useMothershipQueueStore.getState().insertAt(dispatchChatKey, originalIndex, msg) } diff --git a/apps/sim/lib/core/utils/browser-storage.ts b/apps/sim/lib/core/utils/browser-storage.ts index af43319d717..f19b90934b8 100644 --- a/apps/sim/lib/core/utils/browser-storage.ts +++ b/apps/sim/lib/core/utils/browser-storage.ts @@ -4,6 +4,7 @@ */ import { createLogger } from '@sim/logger' +import type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' import type { ChatContext } from '@/stores/panel' const logger = createLogger('BrowserStorage') @@ -307,6 +308,8 @@ export interface MothershipHandoff { message?: string /** Structured contexts to attach — e.g. a `logs` mention tagging a run. */ contexts?: ChatContext[] + /** Already-uploaded attachment references riding along with the message. */ + fileAttachments?: FileAttachmentForApi[] } interface StoredHandoff extends MothershipHandoff { @@ -353,6 +356,7 @@ export class MothershipHandoffStorage { contexts: message ? contexts : [...MothershipHandoffStorage.pendingContexts(workspaceId), ...contexts], + ...(handoff.fileAttachments?.length ? { fileAttachments: handoff.fileAttachments } : {}), workspaceId, timestamp: Date.now(), }) @@ -409,7 +413,13 @@ export class MothershipHandoffStorage { return null } - return { ...(data.message ? { message: data.message } : {}), contexts } + return { + ...(data.message ? { message: data.message } : {}), + contexts, + ...(Array.isArray(data.fileAttachments) && data.fileAttachments.length > 0 + ? { fileAttachments: data.fileAttachments } + : {}), + } } static clear(): boolean { From fc881bfd8860924646545bd974ea365b2c51bb59 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:00:13 -0700 Subject: [PATCH 09/29] feat(search): improve command palette results --- .../command-items/command-items.test.tsx | 84 +++ .../command-items/command-items.tsx | 293 ++++++++-- .../search-modal/components/index.ts | 2 + .../components/search-groups/index.ts | 1 + .../search-groups/search-groups.tsx | 254 +++++++- .../components/search-modal/search-modal.tsx | 542 ++++++++++-------- .../components/search-modal/utils.test.ts | 127 +++- .../sidebar/components/search-modal/utils.ts | 188 +++++- .../modals/search/favorites/store.test.ts | 45 ++ .../stores/modals/search/favorites/store.ts | 32 ++ 10 files changed, 1249 insertions(+), 319 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx create mode 100644 apps/sim/stores/modals/search/favorites/store.test.ts create mode 100644 apps/sim/stores/modals/search/favorites/store.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx new file mode 100644 index 00000000000..0bd37cee9d8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoizedActionItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' + +interface TestIconProps { + className?: string +} + +function TestIcon({ className }: TestIconProps) { + return +} + +describe('MemoizedActionItem', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + }) + + it('toggles its pin without selecting the enclosing command row', () => { + const onSelect = vi.fn() + const onTogglePin = vi.fn() + + act(() => { + root.render( + + + + + + ) + }) + + const pinButton = container.querySelector( + 'button[aria-label="Add to favorites"]' + ) + expect(pinButton).not.toBeNull() + + act(() => pinButton?.click()) + + expect(onTogglePin).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index df0a88afd69..62bec44f210 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -1,15 +1,73 @@ 'use client' -import type { ComponentType } from 'react' +import type { ComponentType, KeyboardEvent, MouseEvent, PointerEvent } from 'react' import { memo } from 'react' -import { cn } from '@sim/emcn' -import { File, Workflow } from '@sim/emcn/icons' -import { WorkflowTypeIcon } from '@sim/workflow-renderer' +import { ChipTag, cn } from '@sim/emcn' +import { File, Pin, Workflow } from '@sim/emcn/icons' +import { getMappedWorkflowTypeAccent } from '@sim/workflow-renderer' import { Command } from 'cmdk' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { getTileIconColorClass } from '@/blocks/icon-color' +interface ResultAdornmentProps { + meta?: string + pinned?: boolean + onTogglePin?: () => void +} + +function stopRowSelection(event: PointerEvent) { + event.preventDefault() + event.stopPropagation() +} + +function stopEnterRowSelection(event: KeyboardEvent) { + if (event.key === 'Enter') event.stopPropagation() +} + +interface PinButtonProps { + pinned?: boolean + onTogglePin: () => void + hasOtherRightContent: boolean +} + +function PinButton({ pinned, onTogglePin, hasOtherRightContent }: PinButtonProps) { + const handleClick = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + onTogglePin() + } + + return ( + + ) +} + +interface ItemMetaProps { + meta: string +} + +function ItemMeta({ meta }: ItemMetaProps) { + return ( + {meta} + ) +} + export const MemoizedCommandItem = memo( function CommandItem({ value, @@ -19,11 +77,22 @@ export const MemoizedCommandItem = memo( showColoredIcon, workflowType, label, + meta, + pinned, + onTogglePin, }: CommandItemProps) { + const workflowAccent = workflowType ? getMappedWorkflowTypeAccent(workflowType) : null + return ( - {workflowType ? ( - + {workflowAccent ? ( + + + ) : (
)} {label} + {meta && } + {onTogglePin && ( + + )} ) }, @@ -49,7 +126,9 @@ export const MemoizedCommandItem = memo( prev.bgColor === next.bgColor && prev.showColoredIcon === next.showColoredIcon && prev.workflowType === next.workflowType && - prev.label === next.label + prev.label === next.label && + prev.meta === next.meta && + prev.pinned === next.pinned ) export const MemoizedActionItem = memo( @@ -59,21 +138,33 @@ export const MemoizedActionItem = memo( icon: Icon, name, shortcut, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - }) { + } & ResultAdornmentProps) { return ( {name} - {shortcut && ( + {meta ? ( + + ) : shortcut ? ( {shortcut} + ) : null} + {onTogglePin && ( + )} ) @@ -82,38 +173,11 @@ export const MemoizedActionItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut + prev.shortcut === next.shortcut && + prev.meta === next.meta && + prev.pinned === next.pinned ) -/** - * Right-aligned folder breadcrumb. All but the last segment collapse first so a - * deep path degrades to the immediate parent rather than truncating the whole - * trail. Renders nothing at the workspace root. - */ -function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) { - if (!folderPath || folderPath.length === 0) return null - return ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - - ) -} - -/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */ -function sameFolderPath(a?: string[], b?: string[]): boolean { - if (a === b) return true - if (a?.length !== b?.length) return false - return (a ?? []).every((segment, i) => segment === b?.[i]) -} - export const MemoizedWorkflowItem = memo( function WorkflowItem({ value, @@ -121,13 +185,16 @@ export const MemoizedWorkflowItem = memo( name, folderPath, isCurrent, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void name: string folderPath?: string[] isCurrent?: boolean - }) { + } & ResultAdornmentProps) { return (
@@ -137,7 +204,28 @@ export const MemoizedWorkflowItem = memo( {name} {isCurrent && (current)} - + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + {folderPath.length > 1 && ( + <> + + {folderPath.slice(0, -1).join(' / ')} + + / + + )} + {folderPath[folderPath.length - 1]} + + ) : null} + {onTogglePin && ( + + )} ) }, @@ -145,7 +233,11 @@ export const MemoizedWorkflowItem = memo( prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent && - sameFolderPath(prev.folderPath, next.folderPath) + prev.meta === next.meta && + prev.pinned === next.pinned && + (prev.folderPath === next.folderPath || + (prev.folderPath?.length === next.folderPath?.length && + (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) ) export const MemoizedFileItem = memo( @@ -154,28 +246,56 @@ export const MemoizedFileItem = memo( onSelect, name, folderPath, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void name: string folderPath?: string[] - }) { + } & ResultAdornmentProps) { return (
- + {name} - + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + {folderPath.length > 1 && ( + <> + + {folderPath.slice(0, -1).join(' / ')} + + / + + )} + {folderPath[folderPath.length - 1]} + + ) : null} + {onTogglePin && ( + + )}
) }, (prev, next) => prev.value === next.value && prev.name === next.name && - sameFolderPath(prev.folderPath, next.folderPath) + prev.meta === next.meta && + prev.pinned === next.pinned && + (prev.folderPath === next.folderPath || + (prev.folderPath?.length === next.folderPath?.length && + (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) ) export const MemoizedTaskItem = memo( @@ -183,18 +303,33 @@ export const MemoizedTaskItem = memo( value, onSelect, name, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void name: string - }) { + } & ResultAdornmentProps) { return ( {name} + {meta && } + {onTogglePin && ( + + )} ) }, - (prev, next) => prev.value === next.value && prev.name === next.name + (prev, next) => + prev.value === next.value && + prev.name === next.name && + prev.meta === next.meta && + prev.pinned === next.pinned ) export const MemoizedWorkspaceItem = memo( @@ -203,23 +338,38 @@ export const MemoizedWorkspaceItem = memo( onSelect, name, isCurrent, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void name: string isCurrent?: boolean - }) { + } & ResultAdornmentProps) { return ( {name} {isCurrent && (current)} + {meta && } + {onTogglePin && ( + + )} ) }, (prev, next) => - prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent + prev.value === next.value && + prev.name === next.name && + prev.isCurrent === next.isCurrent && + prev.meta === next.meta && + prev.pinned === next.pinned ) export const MemoizedPageItem = memo( @@ -229,21 +379,33 @@ export const MemoizedPageItem = memo( icon: Icon, name, shortcut, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - }) { + } & ResultAdornmentProps) { return ( {name} - {shortcut && ( + {meta ? ( + + ) : shortcut ? ( {shortcut} + ) : null} + {onTogglePin && ( + )} ) @@ -252,7 +414,9 @@ export const MemoizedPageItem = memo( prev.value === next.value && prev.icon === next.icon && prev.name === next.name && - prev.shortcut === next.shortcut + prev.shortcut === next.shortcut && + prev.meta === next.meta && + prev.pinned === next.pinned ) export const MemoizedIconItem = memo( @@ -261,21 +425,27 @@ export const MemoizedIconItem = memo( onSelect, name, icon: Icon, - folderPath, + meta, + pinned, + onTogglePin, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> - folderPath?: string[] - }) { + } & ResultAdornmentProps) { return ( - - {name} - - + {name} + {meta && } + {onTogglePin && ( + + )} ) }, @@ -283,5 +453,6 @@ export const MemoizedIconItem = memo( prev.value === next.value && prev.name === next.name && prev.icon === next.icon && - sameFolderPath(prev.folderPath, next.folderPath) + prev.meta === next.meta && + prev.pinned === next.pinned ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts index 18f0ffa3025..8b857485ce2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts @@ -8,6 +8,7 @@ export { MemoizedWorkspaceItem, } from './command-items' export { + ActionsGroup, BlocksGroup, ChatsGroup, ConnectedAccountsGroup, @@ -16,6 +17,7 @@ export { IntegrationsGroup, KnowledgeBasesGroup, PagesGroup, + SearchEntryGroup, TablesGroup, ToolOpsGroup, ToolsGroup, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts index 151685fd917..18b43c2ae4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts @@ -8,6 +8,7 @@ export { IntegrationsGroup, KnowledgeBasesGroup, PagesGroup, + SearchEntryGroup, TablesGroup, ToolOpsGroup, ToolsGroup, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index d3589afeb77..4a1d4a516bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ComponentType } from 'react' +import type { ComponentType, ReactElement } from 'react' import { memo } from 'react' import { Database, Table } from '@sim/emcn/icons' import { Command } from 'cmdk' @@ -20,11 +20,17 @@ import type { FolderedItem, IntegrationSearchItem, PageItem, + SearchEntry, + SearchEntryHandlers, TaskItem, WorkflowItem, WorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +import { + GROUP_HEADING_CLASSNAME, + SECTION_LABELS, + searchEntryKey, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import type { SearchBlockItem, SearchDocItem, @@ -367,3 +373,247 @@ function createIconGroup( ) }) } + +interface RenderEntryOptions { + keyPrefix: string + meta?: string + pinned: boolean + onTogglePin: () => void +} + +function renderSearchEntry( + entry: SearchEntry, + handlers: SearchEntryHandlers, + options: RenderEntryOptions +): ReactElement { + const key = `${options.keyPrefix}${entry.section}-${entry.item.id}` + const rowProps = { + meta: options.meta, + pinned: options.pinned, + onTogglePin: options.onTogglePin, + } + + switch (entry.section) { + case 'actions': + return ( + handlers.onSelectAction(entry.item)} + icon={entry.item.icon} + name={entry.item.name} + shortcut={entry.item.shortcut} + {...rowProps} + /> + ) + case 'connectedAccounts': + return ( + handlers.onSelectConnectedAccount(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'integrations': + return ( + handlers.onSelectIntegration(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'blocks': + return ( + handlers.onSelectBlock(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + workflowType={entry.item.type} + label={entry.item.name} + {...rowProps} + /> + ) + case 'tools': + return ( + handlers.onSelectTool(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'triggers': + return ( + handlers.onSelectTrigger(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'chats': + return ( + handlers.onSelectChat(entry.item)} + name={entry.item.name} + {...rowProps} + /> + ) + case 'workflows': + return ( + handlers.onSelectWorkflow(entry.item)} + name={entry.item.name} + folderPath={entry.item.folderPath} + isCurrent={entry.item.isCurrent} + {...rowProps} + /> + ) + case 'tables': + return ( + handlers.onSelectTable(entry.item)} + name={entry.item.name} + icon={Table} + {...rowProps} + /> + ) + case 'files': + return ( + handlers.onSelectFile(entry.item)} + name={entry.item.name} + folderPath={entry.item.folderPath} + {...rowProps} + /> + ) + case 'knowledgeBases': + return ( + handlers.onSelectKnowledgeBase(entry.item)} + name={entry.item.name} + icon={Database} + {...rowProps} + /> + ) + case 'toolOperations': + return ( + handlers.onSelectToolOperation(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'workspaces': + return ( + handlers.onSelectWorkspace(entry.item)} + name={entry.item.name} + isCurrent={entry.item.isCurrent} + {...rowProps} + /> + ) + case 'docs': + return ( + handlers.onSelectDoc(entry.item)} + icon={entry.item.icon} + bgColor='#6B7280' + showColoredIcon + label={entry.item.name} + {...rowProps} + /> + ) + case 'pages': + return ( + handlers.onSelectPage(entry.item)} + icon={entry.item.icon} + name={entry.item.name} + shortcut={entry.item.shortcut} + {...rowProps} + /> + ) + } +} + +interface SearchEntryGroupProps { + variant: 'section' | 'topMatch' | 'favorites' + heading?: string + entries: SearchEntry[] + handlers: SearchEntryHandlers + favorites: ReadonlySet + onToggleFavorite: (entry: SearchEntry) => void +} + +/** Renders ordinary and aggregate rows with their existing section chrome. */ +export const SearchEntryGroup = memo(function SearchEntryGroup({ + variant, + heading, + entries, + handlers, + favorites, + onToggleFavorite, +}: SearchEntryGroupProps) { + if (entries.length === 0) return null + + const aggregate = variant !== 'section' + const groupHeading = + variant === 'topMatch' ? 'Top Match' : variant === 'favorites' ? 'Favorites' : heading + const keyPrefix = aggregate ? `${variant}-` : '' + + return ( + + {entries.map((entry) => + renderSearchEntry(entry, handlers, { + keyPrefix, + meta: aggregate ? SECTION_LABELS[entry.section] : undefined, + pinned: favorites.has(searchEntryKey(entry)), + onTogglePin: () => onToggleFavorite(entry), + }) + )} + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 953952d2698..e4d7906bd34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,13 +1,13 @@ 'use client' import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { cn, Library, useNativeSurfaceOcclusionReady } from '@sim/emcn' +import { cn, Library } from '@sim/emcn' import { + Calendar, Database, Duplicate, File, FolderPlus, - Hammer, HelpCircle, Home, Integration, @@ -15,7 +15,6 @@ import { Play, Plus, Search, - SelectAll, Send, Settings, Table, @@ -23,14 +22,36 @@ import { } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { Command } from 'cmdk' +import { Scan } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' -import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' import { captureEvent } from '@/lib/posthog/client' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups' +import type { + ActionItem, + FileItem, + IntegrationSearchItem, + PageItem, + SearchEntry, + SearchEntryHandlers, + SearchModalProps, + TaskItem, + WorkflowItem, + WorkspaceItem, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +import { + getGlobalTopMatches, + getSectionNameMatches, + MAX_RESULTS_PER_GROUP, + SECTION_LABELS, + scoreActions, + scoreSectionItems, + searchEntryKey, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { CMDK_ITEM_GAP_CLASS, CMDK_SECTION_GAP_CLASS, @@ -38,6 +59,7 @@ import { import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useSearchFavoritesStore } from '@/stores/modals/search/favorites/store' import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, @@ -45,38 +67,11 @@ import type { SearchSection, SearchToolOperationItem, } from '@/stores/modals/search/types' -import { - ActionsGroup, - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './components/search-groups' -import type { - ActionItem, - FileItem, - IntegrationSearchItem, - PageItem, - SearchModalProps, - TaskItem, - WorkflowItem, - WorkspaceItem, -} from './utils' -import { filterAndCap, filterAndSort } from './utils' +import { SEARCH_SECTIONS } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') -export type { SearchModalProps } from './utils' +export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' export function SearchModal({ open, @@ -102,12 +97,6 @@ export function SearchModal({ const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) const [mounted, setMounted] = useState(false) - const atomicBrowserOcclusion = supportsAtomicBrowserPanelOcclusion() - const nativeSurfaceReady = useNativeSurfaceOcclusionReady(open, 'modal') - const visuallyOpen = open && nativeSurfaceReady - const [retainNativeSurfaceOcclusion, setRetainNativeSurfaceOcclusion] = useState(open) - const nativeSurfaceOcclusionActive = - open || (atomicBrowserOcclusion && retainNativeSurfaceOcclusion) const { navigateToSettings } = useSettingsNavigation() const { config: permissionConfig } = usePermissionConfig() const invokeCommand = useInvokeGlobalCommand() @@ -124,24 +113,18 @@ export function SearchModal({ setMounted(true) }, []) - useEffect(() => { - if (!atomicBrowserOcclusion) return - if (open) { - setRetainNativeSurfaceOcclusion(true) - return - } - // Transition-end normally releases this first. The fallback covers - // reduced-motion/user-agent cases where no transition event is emitted. - const timeout = window.setTimeout(() => setRetainNativeSurfaceOcclusion(false), 200) - return () => window.clearTimeout(timeout) - }, [atomicBrowserOcclusion, open]) - const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( (state) => state.data ) const sections = useSearchModalStore((state) => state.sections) - const showSection = (key: SearchSection) => !sections || sections.includes(key) + const displaySections = useMemo( + () => SEARCH_SECTIONS.filter((section) => !sections || sections.includes(section)), + [sections] + ) + const favoriteKeys = useSearchFavoritesStore((state) => state.favorites) + const toggleFavorite = useSearchFavoritesStore((state) => state.toggleFavorite) + const favoriteSet = useMemo(() => new Set(favoriteKeys), [favoriteKeys]) const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) @@ -157,13 +140,6 @@ export function SearchModal({ href: `/workspace/${workspaceId}/integrations`, hidden: permissionConfig.hideIntegrationsTab, }, - { - id: 'skills', - name: 'Skills', - icon: Hammer, - href: `/workspace/${workspaceId}/skills`, - hidden: permissionConfig.hideIntegrationsTab, - }, { id: 'tables', name: 'Tables', @@ -180,11 +156,17 @@ export function SearchModal({ }, { id: 'knowledge-base', - name: 'Knowledge bases', + name: 'Knowledge base', icon: Database, href: `/workspace/${workspaceId}/knowledge`, hidden: permissionConfig.hideKnowledgeBaseTab, }, + { + id: 'scheduled-tasks', + name: 'Scheduled tasks', + icon: Calendar, + href: `/workspace/${workspaceId}/scheduled-tasks`, + }, { id: 'logs', name: 'Logs', @@ -282,7 +264,7 @@ export function SearchModal({ id: 'fit-to-view', name: 'Fit workflow to view', keywords: 'zoom center recenter canvas reset', - icon: SelectAll, + icon: Scan, shortcut: '⌘⇧F', context: 'workflow', run: () => invokeCommand('fit-to-view'), @@ -325,12 +307,8 @@ export function SearchModal({ if (open) setSearch('') } - /** - * Focus only once the dialog is actually visible: `.focus()` is a no-op while - * the surface still carries `invisible`, and nothing re-focuses afterwards. - */ useEffect(() => { - if (!visuallyOpen || !inputRef.current) return + if (!open || !inputRef.current) return const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' @@ -340,7 +318,7 @@ export function SearchModal({ inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) } inputRef.current.focus() - }, [visuallyOpen]) + }, [open]) const deferredSearch = useDeferredValue(search) const deferredSearchRef = useRef(deferredSearch) @@ -592,128 +570,223 @@ export function SearchModal({ onOpenChangeRef.current(false) }, []) - const filteredActions = useMemo(() => { - const available = actions.filter( - (a) => - a.context === 'global' || - (a.context === 'workflow' && isOnWorkflowPage) || - (a.context === 'integrations' && isOnIntegrationsPage) - ) - return filterAndSort(available, (a) => `${a.name} ${a.keywords ?? ''}`, deferredSearch) - }, [actions, isOnWorkflowPage, isOnIntegrationsPage, deferredSearch]) - - /** - * Blocks and tools rank by name first, with `searchValue` (type + option - * labels) as a lower-tier fallback, so an exact name match wins while a block - * stays findable by an option label. - */ - const filteredBlocks = useMemo(() => { - if (!isOnWorkflowPage) return [] - // A custom block is hidden on its own source workflow's canvas — placing it - // there recurses (same exclusion as the toolbar). - return filterAndCap( - blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId), - (b) => b.name, - deferredSearch, - (b) => b.searchValue - ) - }, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId]) - - const filteredTools = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap( - tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId), - (t) => t.name, - deferredSearch, - (t) => t.searchValue - ) - }, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId]) - - const filteredTriggers = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap(triggers, (t) => `${t.name} ${t.id}`, deferredSearch) - }, [isOnWorkflowPage, triggers, deferredSearch]) - - const filteredToolOps = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap( - toolOperations, - (op) => op.name, - deferredSearch, - (op) => op.searchValue + const entriesBySection = useMemo((): Record => { + const query = deferredSearch.trim() + const visibleSections = new Set(displaySections) + const rank = ( + section: SearchSection, + items: T[], + toValue: (item: T) => string, + toExtra?: (item: T) => string | undefined + ) => { + if (!visibleSections.has(section)) return [] + return query + ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra).slice( + 0, + MAX_RESULTS_PER_GROUP + ) + : items.map((item) => ({ item, score: 0 })) + } + const availableActions = actions.filter( + (action) => + action.context === 'global' || + (action.context === 'workflow' && isOnWorkflowPage) || + (action.context === 'integrations' && isOnIntegrationsPage) ) - }, [isOnWorkflowPage, toolOperations, deferredSearch]) - - const filteredDocs = useMemo(() => { - if (!isOnWorkflowPage) return [] - return filterAndCap(docs, (d) => `${d.name} docs documentation`, deferredSearch) - }, [isOnWorkflowPage, docs, deferredSearch]) - - const filteredTables = useMemo( - () => - filterAndCap( - tables, - (t) => t.name, - deferredSearch, - (t) => t.folderPath?.join(' ') - ), - [tables, deferredSearch] - ) - const filteredFiles = useMemo( - () => - filterAndCap( + const rankedActions = visibleSections.has('actions') + ? query + ? scoreActions(availableActions, deferredSearch) + : availableActions.map((item) => ({ item, score: 0 })) + : [] + const availableBlocks = isOnWorkflowPage + ? blocks.filter( + (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId + ) + : [] + const availableTools = isOnWorkflowPage + ? tools.filter( + (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId + ) + : [] + const rankedIntegrations = + isOnIntegrationsPage && query ? rank('integrations', integrations, (item) => item.name) : [] + + return { + actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), + connectedAccounts: (isOnIntegrationsPage + ? rank('connectedAccounts', connectedAccounts, (item) => item.name) + : [] + ).map(({ item, score }) => ({ section: 'connectedAccounts', item, score })), + integrations: rankedIntegrations.map(({ item, score }) => ({ + section: 'integrations', + item, + score, + })), + blocks: rank( + 'blocks', + availableBlocks, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'blocks', item, score })), + tools: rank( + 'tools', + availableTools, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'tools', item, score })), + triggers: rank( + 'triggers', + isOnWorkflowPage ? triggers : [], + (item) => item.name, + (item) => `${item.name} ${item.id}` + ).map(({ item, score }) => ({ section: 'triggers', item, score })), + chats: rank('chats', chats, (item) => item.name).map(({ item, score }) => ({ + section: 'chats', + item, + score, + })), + workflows: rank( + 'workflows', + workflows, + (item) => item.name, + (item) => item.folderPath?.join(' ') + ).map(({ item, score }) => ({ section: 'workflows', item, score })), + tables: rank('tables', tables, (item) => item.name).map(({ item, score }) => ({ + section: 'tables', + item, + score, + })), + files: rank( + 'files', files, - (f) => f.name, - deferredSearch, - (f) => f.folderPath?.join(' ') - ), - [files, deferredSearch] - ) - const filteredKnowledgeBases = useMemo( - () => - filterAndCap( - knowledgeBases, - (kb) => kb.name, - deferredSearch, - (kb) => kb.folderPath?.join(' ') + (item) => item.name, + (item) => item.folderPath?.join(' ') + ).map(({ item, score }) => ({ section: 'files', item, score })), + knowledgeBases: rank('knowledgeBases', knowledgeBases, (item) => item.name).map( + ({ item, score }) => ({ section: 'knowledgeBases', item, score }) ), - [knowledgeBases, deferredSearch] - ) + toolOperations: rank( + 'toolOperations', + isOnWorkflowPage ? toolOperations : [], + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), + workspaces: rank('workspaces', workspaces, (item) => item.name).map(({ item, score }) => ({ + section: 'workspaces', + item, + score, + })), + docs: rank( + 'docs', + isOnWorkflowPage ? docs : [], + (item) => item.name, + (item) => `${item.name} docs documentation` + ).map(({ item, score }) => ({ section: 'docs', item, score })), + pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ + section: 'pages', + item, + score, + })), + } + }, [ + deferredSearch, + displaySections, + actions, + isOnWorkflowPage, + isOnIntegrationsPage, + blocks, + currentWorkflowId, + tools, + integrations, + connectedAccounts, + triggers, + chats, + workflows, + tables, + files, + knowledgeBases, + toolOperations, + workspaces, + docs, + pages, + ]) - const filteredWorkflows = useMemo( - () => - filterAndCap( - workflows, - (w) => w.name, - deferredSearch, - (w) => w.folderPath?.join(' ') - ), - [workflows, deferredSearch] - ) - const filteredChats = useMemo( - () => filterAndCap(chats, (t) => t.name, deferredSearch), - [chats, deferredSearch] - ) - const filteredWorkspaces = useMemo( - () => filterAndCap(workspaces, (w) => w.name, deferredSearch), - [workspaces, deferredSearch] - ) - const filteredPages = useMemo( - () => filterAndSort(pages, (p) => p.name, deferredSearch), - [pages, deferredSearch] + const { matchingSectionGroups, remainingSectionGroups } = useMemo(() => { + const matchingSections = getSectionNameMatches(displaySections, deferredSearch) + const matchingSet = new Set(matchingSections) + const toGroup = (section: SearchSection) => ({ + section, + entries: entriesBySection[section], + }) + return { + matchingSectionGroups: matchingSections.map(toGroup), + remainingSectionGroups: displaySections + .filter((section) => !matchingSet.has(section)) + .map(toGroup), + } + }, [deferredSearch, displaySections, entriesBySection]) + + const globalTopMatches = useMemo( + () => (deferredSearch.trim() ? getGlobalTopMatches(entriesBySection, displaySections) : []), + [deferredSearch, entriesBySection, displaySections] ) - /** Connected accounts: visible on the integrations page even with empty input. */ - const filteredConnectedAccounts = useMemo(() => { - if (!isOnIntegrationsPage) return [] - return filterAndCap(connectedAccounts, (a) => a.name, deferredSearch) - }, [isOnIntegrationsPage, connectedAccounts, deferredSearch]) + const favoriteEntries = useMemo((): SearchEntry[] => { + const entriesByKey = new Map() + for (const section of displaySections) { + for (const entry of entriesBySection[section]) { + entriesByKey.set(searchEntryKey(entry), entry) + } + } + const entries: SearchEntry[] = [] + for (const key of favoriteKeys) { + const entry = entriesByKey.get(key) + if (entry) entries.push(entry) + } + return deferredSearch.trim() ? entries.slice(0, MAX_RESULTS_PER_GROUP) : entries + }, [favoriteKeys, displaySections, entriesBySection, deferredSearch]) + + const handleToggleFavorite = useCallback( + (entry: SearchEntry) => toggleFavorite(searchEntryKey(entry)), + [toggleFavorite] + ) - /** Catalog integrations: only shown once the user has typed something. */ - const filteredIntegrations = useMemo(() => { - if (!isOnIntegrationsPage || !deferredSearch.trim()) return [] - return filterAndCap(integrations, (i) => i.name, deferredSearch) - }, [isOnIntegrationsPage, deferredSearch, integrations]) + const entryHandlers = useMemo( + (): SearchEntryHandlers => ({ + onSelectAction: handleActionSelect, + onSelectConnectedAccount: handleConnectedAccountSelect, + onSelectIntegration: handleIntegrationSelect, + onSelectBlock: handleBlockSelectAsBlock, + onSelectTool: handleBlockSelectAsTool, + onSelectTrigger: handleBlockSelectAsTrigger, + onSelectChat: handleChatSelect, + onSelectWorkflow: handleWorkflowSelect, + onSelectTable: handleTableSelect, + onSelectFile: handleFileSelect, + onSelectKnowledgeBase: handleKbSelect, + onSelectToolOperation: handleToolOperationSelect, + onSelectWorkspace: handleWorkspaceSelect, + onSelectDoc: handleDocSelect, + onSelectPage: handlePageSelect, + }), + [ + handleActionSelect, + handleConnectedAccountSelect, + handleIntegrationSelect, + handleBlockSelectAsBlock, + handleBlockSelectAsTool, + handleBlockSelectAsTrigger, + handleChatSelect, + handleWorkflowSelect, + handleTableSelect, + handleFileSelect, + handleKbSelect, + handleToolOperationSelect, + handleWorkspaceSelect, + handleDocSelect, + handlePageSelect, + ] + ) if (!mounted) return null @@ -722,32 +795,20 @@ export function SearchModal({
{ - if ( - atomicBrowserOcclusion && - !open && - event.target === event.currentTarget && - event.propertyName === 'opacity' - ) { - setRetainNativeSurfaceOcclusion(false) - } - }} - aria-hidden={!visuallyOpen} - data-native-surface-occlusion={nativeSurfaceOcclusionActive ? 'modal' : undefined} + aria-hidden={!open} />
- {showSection('actions') && ( - - )} - {showSection('connectedAccounts') && ( - 0 && ( + )} - {showSection('integrations') && ( - ( + + ))} + {favoriteEntries.length > 0 && ( + )} - {showSection('blocks') && ( - - )} - {showSection('tools') && ( - - )} - {showSection('triggers') && ( - - )} - {showSection('chats') && ( - - )} - {showSection('tables') && ( - - )} - {showSection('files') && ( - - )} - {showSection('knowledgeBases') && ( - - )} - {showSection('workflows') && ( - - )} - {showSection('toolOperations') && ( - - )} - {showSection('workspaces') && ( - - )} - {showSection('docs') && } - {showSection('pages') && ( - - )} + {remainingSectionGroups.map(({ section, entries }) => ( + + ))}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 6f5d50a2cf8..455079ba5aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -2,7 +2,132 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { filterAndCap, filterAndSort, fuzzyMatch, MAX_RESULTS_PER_GROUP } from './utils' +import { + filterAndCap, + filterAndSort, + fuzzyMatch, + getGlobalTopMatches, + getSectionNameMatches, + MAX_RESULTS_PER_GROUP, + type SearchEntry, + scoreActions, + scoreAndSort, + scoreSectionItems, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' + +describe('getGlobalTopMatches', () => { + it('merge-ranks results across every visible section', () => { + const action: SearchEntry = { + section: 'actions', + score: 7, + item: { + id: 'create-folder', + name: 'Create folder', + icon: () => null, + context: 'global', + run: () => {}, + }, + } + const workflow: SearchEntry = { + section: 'workflows', + score: 20, + item: { id: 'workflow-1', name: 'New customer workflow', href: '/workflow-1' }, + } + const chat: SearchEntry = { + section: 'chats', + score: 83, + item: { id: 'chat-1', name: 'New chat', href: '/chat-1' }, + } + + const matches = getGlobalTopMatches( + { actions: [action], workflows: [workflow], chats: [chat] }, + ['actions', 'workflows', 'chats'] + ) + + expect(matches.map((entry) => entry.item.id)).toEqual(['chat-1', 'workflow-1', 'create-folder']) + }) + + it('breaks identical visible-name matches by the original section order', () => { + const action = { + id: 'new-chat-action', + name: 'New chat', + keywords: 'message conversation', + icon: () => null, + context: 'global' as const, + run: () => {}, + } + const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' } + const [actionMatch] = scoreActions([action], 'new c') + const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c') + + expect(actionMatch.score).toBe(chatMatch.score) + expect( + getGlobalTopMatches( + { + actions: [{ section: 'actions', ...actionMatch }], + chats: [{ section: 'chats', ...chatMatch }], + }, + ['actions', 'chats'] + ).map((entry) => entry.item.id) + ).toEqual(['new-chat-action', 'new-chat-result']) + }) + + it('keeps only the five highest-scoring entries', () => { + const workflows: SearchEntry[] = Array.from({ length: 8 }, (_, index) => ({ + section: 'workflows', + score: index, + item: { id: `workflow-${index}`, name: `Workflow ${index}`, href: `/workflow-${index}` }, + })) + + expect(getGlobalTopMatches({ workflows }, ['workflows']).map((entry) => entry.item.id)).toEqual( + ['workflow-7', 'workflow-6', 'workflow-5', 'workflow-4', 'workflow-3'] + ) + }) +}) + +describe('getSectionNameMatches', () => { + const sections = ['actions', 'workflows', 'workspaces', 'chats', 'pages'] as const + + it('promotes exact and partial section-name matches', () => { + expect(getSectionNameMatches(sections, 'Workspaces')).toEqual(['workspaces']) + expect(getSectionNameMatches(sections, 'chat')).toEqual(['chats']) + expect(getSectionNameMatches(sections, 'work')).toEqual(['workflows', 'workspaces']) + }) + + it('does not change section priority for non-section or empty queries', () => { + expect(getSectionNameMatches(sections, 'settings')).toEqual([]) + expect(getSectionNameMatches(sections, '')).toEqual([]) + }) +}) + +describe('scoreSectionItems', () => { + it("surfaces a section's items when the query matches the section name", () => { + const chats = [{ name: 'Quarterly planning' }, { name: 'Incident follow-up' }] + + expect(scoreSectionItems('chats', chats, (chat) => chat.name, 'Chats')).toEqual([ + { item: chats[0], score: expect.any(Number) }, + { item: chats[1], score: expect.any(Number) }, + ]) + }) + + it('keeps direct matches first and preserves natural fallback order', () => { + const workspaces = [ + { name: 'Workspaces demo', keywords: 'long metadata' }, + { name: 'Acme', keywords: 'a much longer metadata value' }, + { name: 'Beta', keywords: '' }, + ] + + expect( + scoreSectionItems( + 'workspaces', + workspaces, + (workspace) => workspace.name, + 'workspaces', + (workspace) => workspace.keywords + ).map(({ item }) => item.name) + ).toEqual(['Workspaces demo', 'Acme', 'Beta']) + }) +}) /** * The matcher that shipped before fuzzy matching was introduced. Re-implemented diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index c43d6085fa8..f063de1ceb9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -1,4 +1,10 @@ import type { ComponentType } from 'react' +import type { + SearchBlockItem, + SearchDocItem, + SearchSection, + SearchToolOperationItem, +} from '@/stores/modals/search/types' export interface IntegrationSearchItem { id: string @@ -98,6 +104,114 @@ export interface CommandItemProps { workflowType?: string /** Primary text of the row. */ label: string + /** Right-aligned source section shown in aggregate result groups. */ + meta?: string + /** Whether this result is pinned. */ + pinned?: boolean + /** Toggles this result's pinned state. */ + onTogglePin?: () => void +} + +export const SECTION_LABELS: Record = { + actions: 'Actions', + connectedAccounts: 'Connected', + integrations: 'Integrations', + blocks: 'Blocks', + tools: 'Tools', + triggers: 'Triggers', + chats: 'Chats', + workflows: 'Workflows', + tables: 'Tables', + files: 'Files', + knowledgeBases: 'Knowledge bases', + toolOperations: 'Tool operations', + workspaces: 'Workspaces', + docs: 'Docs', + pages: 'Pages', +} + +export const TOP_MATCH_COUNT = 5 + +export type SearchEntry = + | { section: 'actions'; score: number; item: ActionItem } + | { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem } + | { section: 'blocks' | 'tools' | 'triggers'; score: number; item: SearchBlockItem } + | { section: 'chats'; score: number; item: TaskItem } + | { section: 'workflows'; score: number; item: WorkflowItem } + | { section: 'tables' | 'knowledgeBases'; score: number; item: TaskItem } + | { section: 'files'; score: number; item: FileItem } + | { section: 'toolOperations'; score: number; item: SearchToolOperationItem } + | { section: 'workspaces'; score: number; item: WorkspaceItem } + | { section: 'docs'; score: number; item: SearchDocItem } + | { section: 'pages'; score: number; item: PageItem } + +export interface SearchEntryHandlers { + onSelectAction: (item: ActionItem) => void + onSelectConnectedAccount: (item: IntegrationSearchItem) => void + onSelectIntegration: (item: IntegrationSearchItem) => void + onSelectBlock: (item: SearchBlockItem) => void + onSelectTool: (item: SearchBlockItem) => void + onSelectTrigger: (item: SearchBlockItem) => void + onSelectChat: (item: TaskItem) => void + onSelectWorkflow: (item: WorkflowItem) => void + onSelectTable: (item: TaskItem) => void + onSelectFile: (item: FileItem) => void + onSelectKnowledgeBase: (item: TaskItem) => void + onSelectToolOperation: (item: SearchToolOperationItem) => void + onSelectWorkspace: (item: WorkspaceItem) => void + onSelectDoc: (item: SearchDocItem) => void + onSelectPage: (item: PageItem) => void +} + +/** Stable, section-qualified identity used by the persisted favorites store. */ +export function searchEntryKey(entry: SearchEntry): string { + return `${entry.section}:${entry.item.id}` +} + +/** Merge-ranks visible sections into the five highest-scoring results. */ +export function getGlobalTopMatches( + entriesBySection: Partial>, + sections: readonly SearchSection[] +): SearchEntry[] { + const sectionOrder = new Map(sections.map((section, index) => [section, index])) + const topMatches: Array<{ entry: SearchEntry; originalIndex: number }> = [] + let originalIndex = 0 + + const compare = ( + a: { entry: SearchEntry; originalIndex: number }, + b: { entry: SearchEntry; originalIndex: number } + ) => + b.entry.score - a.entry.score || + (sectionOrder.get(a.entry.section) ?? sections.length) - + (sectionOrder.get(b.entry.section) ?? sections.length) || + a.originalIndex - b.originalIndex + + for (const section of sections) { + for (const entry of entriesBySection[section] ?? []) { + const candidate = { entry, originalIndex } + originalIndex += 1 + const insertionIndex = topMatches.findIndex((current) => compare(candidate, current) < 0) + if (insertionIndex === -1) { + if (topMatches.length < TOP_MATCH_COUNT) topMatches.push(candidate) + continue + } + topMatches.splice(insertionIndex, 0, candidate) + if (topMatches.length > TOP_MATCH_COUNT) topMatches.pop() + } + } + + return topMatches.map(({ entry }) => entry) +} + +/** Returns visible sections whose heading matches the query, strongest first. */ +export function getSectionNameMatches( + sections: readonly SearchSection[], + search: string +): SearchSection[] { + if (!search.trim()) return [] + return scoreAndSort([...sections], (section) => SECTION_LABELS[section], search).map( + ({ item }) => item + ) } export const GROUP_HEADING_CLASSNAME = @@ -256,34 +370,88 @@ const NAME_MATCH_TIER = 1_000_000 */ function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult { const byName = fuzzyMatch(name, search) - if (!extra) return byName if (byName.matched) { return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions } } + if (!extra) return NO_MATCH const byExtra = fuzzyMatch(extra, search) return byExtra.matched ? byExtra : NO_MATCH } -/** - * Filters and ranks items by fuzzy match, highest score first; returns the input - * unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank - * the name first and fall back to secondary text. - */ -export function filterAndSort( +/** Scores and sorts matches while retaining scores for cross-section ranking. */ +export function scoreAndSort( items: T[], toValue: (item: T) => string, search: string, toExtra?: (item: T) => string | undefined -): T[] { +): Array<{ item: T; score: number }> { const query = search.trim() - if (!query) return items const scored: Array<{ item: T; score: number }> = [] for (const item of items) { const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query) if (matched) scored.push({ item, score }) } scored.sort((a, b) => b.score - a.score) - return scored.map((entry) => entry.item) + return scored +} + +/** + * Scores normal item matches first, then fills a matched section with its + * remaining rows in natural order. + */ +export function scoreSectionItems( + section: SearchSection, + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): Array<{ item: T; score: number }> { + const rankedItems = scoreAndSort(items, toValue, search, toExtra) + const sectionMatch = fuzzyMatch(SECTION_LABELS[section], search.trim()) + if (!sectionMatch.matched) return rankedItems + + const matchedItems = new Set(rankedItems.map(({ item }) => item)) + const lowestItemScore = rankedItems.at(-1)?.score + const fallbackScore = + lowestItemScore === undefined + ? sectionMatch.score + : Math.min(sectionMatch.score, lowestItemScore - 1) + + return [ + ...rankedItems, + ...items + .filter((item) => !matchedItems.has(item)) + .map((item) => ({ item, score: fallbackScore })), + ] +} + +/** Scores actions by visible name before falling back to their keywords. */ +export function scoreActions( + actions: ActionItem[], + search: string +): Array<{ item: ActionItem; score: number }> { + return scoreSectionItems( + 'actions', + actions, + (action) => action.name, + search, + (action) => `${action.name} ${action.keywords ?? ''}` + ) +} + +/** + * Filters and ranks items by fuzzy match, highest score first; returns the input + * unchanged when the search is empty or whitespace-only. Pass `toExtra` to rank + * the name first and fall back to secondary text. + */ +export function filterAndSort( + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined +): T[] { + if (!search.trim()) return items + return scoreAndSort(items, toValue, search, toExtra).map((entry) => entry.item) } /** diff --git a/apps/sim/stores/modals/search/favorites/store.test.ts b/apps/sim/stores/modals/search/favorites/store.test.ts new file mode 100644 index 00000000000..939dd88c822 --- /dev/null +++ b/apps/sim/stores/modals/search/favorites/store.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it } from 'vitest' +import { useSearchFavoritesStore } from '@/stores/modals/search/favorites/store' + +describe('useSearchFavoritesStore', () => { + beforeEach(() => { + localStorage.clear() + useSearchFavoritesStore.getState().reset() + }) + + it('pins and unpins section-qualified entries in selection order', () => { + const store = useSearchFavoritesStore.getState() + + store.toggleFavorite('chats:chat-1') + store.toggleFavorite('workflows:workflow-1') + expect(useSearchFavoritesStore.getState().favorites).toEqual([ + 'chats:chat-1', + 'workflows:workflow-1', + ]) + + useSearchFavoritesStore.getState().toggleFavorite('chats:chat-1') + expect(useSearchFavoritesStore.getState().favorites).toEqual(['workflows:workflow-1']) + }) + + it('resets all pinned entries', () => { + useSearchFavoritesStore.getState().toggleFavorite('pages:settings') + useSearchFavoritesStore.getState().reset() + + expect(useSearchFavoritesStore.getState().favorites).toEqual([]) + }) + + it('rehydrates pinned entries from local storage', async () => { + useSearchFavoritesStore.setState({ favorites: [] }) + localStorage.setItem( + 'search-favorites', + JSON.stringify({ state: { favorites: ['chats:chat-1'] }, version: 0 }) + ) + + await useSearchFavoritesStore.persist.rehydrate() + + expect(useSearchFavoritesStore.getState().favorites).toEqual(['chats:chat-1']) + }) +}) diff --git a/apps/sim/stores/modals/search/favorites/store.ts b/apps/sim/stores/modals/search/favorites/store.ts new file mode 100644 index 00000000000..aea3664e9af --- /dev/null +++ b/apps/sim/stores/modals/search/favorites/store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +export interface SearchFavoritesState { + favorites: string[] + toggleFavorite: (key: string) => void + reset: () => void +} + +const initialState = { favorites: [] as string[] } + +export const useSearchFavoritesStore = create()( + devtools( + persist( + (set) => ({ + ...initialState, + toggleFavorite: (key) => + set((state) => ({ + favorites: state.favorites.includes(key) + ? state.favorites.filter((favorite) => favorite !== key) + : [...state.favorites, key], + })), + reset: () => set((state) => (state.favorites.length === 0 ? state : initialState)), + }), + { + name: 'search-favorites', + partialize: (state) => ({ favorites: state.favorites }), + } + ), + { name: 'search-favorites-store' } + ) +) From a4625ab16dc4d86c72dcad1329380d38a4533d23 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:47:52 -0700 Subject: [PATCH 10/29] feat(search): sharpen command palette discovery Unify command surfaces, flatten ranked results, and remove favorites so the palette stays focused on fast discovery. Add Tab result cycling and workspace identity icons for quicker keyboard navigation. --- .../connection-block-selector.tsx | 32 +-- .../command-chrome/command-chrome.test.tsx | 131 ++++++++++ .../command-chrome/command-chrome.tsx | 89 +++++++ .../components/command-chrome/index.ts | 1 + .../command-items/command-items.test.tsx | 29 +-- .../command-items/command-items.tsx | 226 ++++++------------ .../search-modal/components/index.ts | 1 + .../search-groups/search-groups.test.tsx | 159 ++++++++++++ .../search-groups/search-groups.tsx | 56 +++-- .../components/search-modal/search-modal.tsx | 200 +++++++--------- .../components/search-modal/utils.test.ts | 118 +++++++-- .../sidebar/components/search-modal/utils.ts | 136 +++++++---- .../w/components/sidebar/sidebar.tsx | 5 +- .../modals/search/favorites/store.test.ts | 45 ---- .../stores/modals/search/favorites/store.ts | 32 --- apps/sim/stores/modals/search/store.ts | 1 + apps/sim/stores/modals/search/types.ts | 8 +- 17 files changed, 794 insertions(+), 475 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx delete mode 100644 apps/sim/stores/modals/search/favorites/store.test.ts delete mode 100644 apps/sim/stores/modals/search/favorites/store.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 157f6f41278..67bc108a039 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -9,6 +9,10 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { Handle, type NodeProps, Position } from 'reactflow' import { captureEvent } from '@/lib/posthog/client' +import { + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' import { MemoizedCommandItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' import { BlocksGroup, @@ -403,10 +407,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps
- )} - -
- - -
+ +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx new file mode 100644 index 00000000000..4118fc26858 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx @@ -0,0 +1,131 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' + +describe('CommandFadedList', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + vi.unstubAllGlobals() + }) + + it('insets the palette scrollbar track below the search field', () => { + act(() => { + root.render( + + + + ) + }) + + const list = container.querySelector('[cmdk-list]') + expect(list?.className).toContain('[&::-webkit-scrollbar-track]:mt-12') + expect(list?.className).toContain('[&::-webkit-scrollbar-track]:mb-1.5') + }) + + it('uses the canvas search surface and content fade in the palette', () => { + act(() => { + root.render( + + + + + ) + }) + + const list = container.querySelector('[cmdk-list]') + const search = container.querySelector('[cmdk-input]')?.parentElement + expect(list?.className).toContain('transparent_8%,black_18%,black_94%') + expect(search?.className).toContain('var(--surface-2)') + }) + + it('cycles through palette results with Tab and Shift+Tab', () => { + act(() => { + root.render( + + + + First + Second + + + ) + }) + + const input = container.querySelector('[cmdk-input]') + const selectedResult = () => + container.querySelector('[cmdk-item][aria-selected="true"]')?.textContent + + expect(input).not.toBeNull() + expect(selectedResult()).toBe('First') + + const firstTabEvent = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }) + act(() => { + input?.focus() + input?.dispatchEvent(firstTabEvent) + }) + expect(selectedResult()).toBe('Second') + expect(firstTabEvent.defaultPrevented).toBe(true) + expect(document.activeElement).toBe(input) + + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(selectedResult()).toBe('First') + + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true, + bubbles: true, + cancelable: true, + }) + ) + }) + expect(selectedResult()).toBe('Second') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx new file mode 100644 index 00000000000..0b5134d50e2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -0,0 +1,89 @@ +'use client' + +import { type ComponentPropsWithoutRef, forwardRef, type KeyboardEvent } from 'react' +import { cn } from '@sim/emcn' +import { Command } from 'cmdk' +import { Search } from 'lucide-react' + +type CommandInputProps = ComponentPropsWithoutRef +type CommandListProps = ComponentPropsWithoutRef + +interface CommandSearchProps extends Omit { + surface: 'canvas' | 'palette' + cycleResultsOnTab?: boolean +} + +interface CommandFadedListProps extends CommandListProps { + fade: 'canvas' | 'palette' +} + +const SEARCH_SURFACE_CLASSNAME = { + canvas: + 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', + palette: + 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', +} as const + +const LIST_FADE_CLASSNAME = { + canvas: + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]', + palette: + '[&::-webkit-scrollbar-track]:mt-12 [&::-webkit-scrollbar-track]:mb-1.5 [-webkit-mask-composite:source-over] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%),linear-gradient(black,black)] [-webkit-mask-position:left_top,right_top] [-webkit-mask-repeat:no-repeat,no-repeat] [-webkit-mask-size:calc(100%_-_8px)_100%,8px_100%] [mask-composite:add] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%),linear-gradient(black,black)] [mask-position:left_top,right_top] [mask-repeat:no-repeat,no-repeat] [mask-size:calc(100%_-_8px)_100%,8px_100%]', +} as const + +/** Borderless search field layered over a fading command-result list. */ +export const CommandSearch = forwardRef( + function CommandSearch({ surface, cycleResultsOnTab = false, onKeyDown, ...props }, ref) { + const handleKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event) + if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return + + event.preventDefault() + event.currentTarget.dispatchEvent( + new window.KeyboardEvent('keydown', { + key: event.shiftKey ? 'ArrowUp' : 'ArrowDown', + bubbles: true, + cancelable: true, + }) + ) + } + + return ( +
+ + +
+ ) + } +) + +CommandSearch.displayName = 'CommandSearch' + +/** Scrollable command list with soft edge fades tuned for each command surface. */ +export const CommandFadedList = forwardRef( + function CommandFadedList({ className, fade, ...props }, ref) { + return ( + + ) + } +) + +CommandFadedList.displayName = 'CommandFadedList' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts new file mode 100644 index 00000000000..cb4a9a8d1e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/index.ts @@ -0,0 +1 @@ +export { CommandFadedList, CommandSearch } from './command-chrome' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx index 0bd37cee9d8..9008feacf7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.test.tsx @@ -51,34 +51,29 @@ describe('MemoizedActionItem', () => { } }) - it('toggles its pin without selecting the enclosing command row', () => { - const onSelect = vi.fn() - const onTogglePin = vi.fn() - + it('centers the command glyph in a fixed three-slot shortcut hint', () => { act(() => { root.render( ) }) - const pinButton = container.querySelector( - 'button[aria-label="Add to favorites"]' - ) - expect(pinButton).not.toBeNull() - - act(() => pinButton?.click()) - - expect(onTogglePin).toHaveBeenCalledTimes(1) - expect(onSelect).not.toHaveBeenCalled() + const shortcut = container.querySelector('[aria-label="Keyboard shortcut ⌘↵"]') + expect(Array.from(shortcut?.children ?? []).map((slot) => slot.textContent)).toEqual([ + '', + '⌘', + '↵', + ]) + expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index 62bec44f210..b0738ad6dab 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -1,70 +1,53 @@ 'use client' -import type { ComponentType, KeyboardEvent, MouseEvent, PointerEvent } from 'react' +import type { ComponentType } from 'react' import { memo } from 'react' import { ChipTag, cn } from '@sim/emcn' -import { File, Pin, Workflow } from '@sim/emcn/icons' +import { File, Workflow } from '@sim/emcn/icons' import { getMappedWorkflowTypeAccent } from '@sim/workflow-renderer' import { Command } from 'cmdk' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { getTileIconColorClass } from '@/blocks/icon-color' -interface ResultAdornmentProps { +interface ResultMetaProps { meta?: string - pinned?: boolean - onTogglePin?: () => void } -function stopRowSelection(event: PointerEvent) { - event.preventDefault() - event.stopPropagation() -} - -function stopEnterRowSelection(event: KeyboardEvent) { - if (event.key === 'Enter') event.stopPropagation() -} - -interface PinButtonProps { - pinned?: boolean - onTogglePin: () => void - hasOtherRightContent: boolean +interface ItemMetaProps { + meta: string } -function PinButton({ pinned, onTogglePin, hasOtherRightContent }: PinButtonProps) { - const handleClick = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - onTogglePin() - } - +function ItemMeta({ meta }: ItemMetaProps) { return ( - + {meta} ) } -interface ItemMetaProps { - meta: string +interface ShortcutHintProps { + shortcut: string } -function ItemMeta({ meta }: ItemMetaProps) { +const WORKSPACE_COLOR_REGEX = /^#[\da-f]{6}$/i + +function ShortcutHint({ shortcut }: ShortcutHintProps) { + const commandIndex = shortcut.indexOf('⌘') + const slots = + commandIndex === -1 + ? ['', '', shortcut] + : [shortcut.slice(0, commandIndex), '⌘', shortcut.slice(commandIndex + 1)] + return ( - {meta} + + {slots.map((slot, index) => ( + + ))} + ) } @@ -78,8 +61,6 @@ export const MemoizedCommandItem = memo( workflowType, label, meta, - pinned, - onTogglePin, }: CommandItemProps) { const workflowAccent = workflowType ? getMappedWorkflowTypeAccent(workflowType) : null @@ -110,13 +91,6 @@ export const MemoizedCommandItem = memo( )} {label} {meta && } - {onTogglePin && ( - - )} ) }, @@ -127,8 +101,7 @@ export const MemoizedCommandItem = memo( prev.showColoredIcon === next.showColoredIcon && prev.workflowType === next.workflowType && prev.label === next.label && - prev.meta === next.meta && - prev.pinned === next.pinned + prev.meta === next.meta ) export const MemoizedActionItem = memo( @@ -139,33 +112,18 @@ export const MemoizedActionItem = memo( name, shortcut, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - } & ResultAdornmentProps) { + } & ResultMetaProps) { return ( {name} - {meta ? ( - - ) : shortcut ? ( - - {shortcut} - - ) : null} - {onTogglePin && ( - - )} + {meta ? : shortcut ? : null} ) }, @@ -174,8 +132,7 @@ export const MemoizedActionItem = memo( prev.icon === next.icon && prev.name === next.name && prev.shortcut === next.shortcut && - prev.meta === next.meta && - prev.pinned === next.pinned + prev.meta === next.meta ) export const MemoizedWorkflowItem = memo( @@ -186,15 +143,13 @@ export const MemoizedWorkflowItem = memo( folderPath, isCurrent, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void name: string folderPath?: string[] isCurrent?: boolean - } & ResultAdornmentProps) { + } & ResultMetaProps) { return (
@@ -219,13 +174,6 @@ export const MemoizedWorkflowItem = memo( {folderPath[folderPath.length - 1]} ) : null} - {onTogglePin && ( - - )} ) }, @@ -234,7 +182,6 @@ export const MemoizedWorkflowItem = memo( prev.name === next.name && prev.isCurrent === next.isCurrent && prev.meta === next.meta && - prev.pinned === next.pinned && (prev.folderPath === next.folderPath || (prev.folderPath?.length === next.folderPath?.length && (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) @@ -247,14 +194,12 @@ export const MemoizedFileItem = memo( name, folderPath, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void name: string folderPath?: string[] - } & ResultAdornmentProps) { + } & ResultMetaProps) { return (
@@ -278,13 +223,6 @@ export const MemoizedFileItem = memo( {folderPath[folderPath.length - 1]} ) : null} - {onTogglePin && ( - - )} ) }, @@ -292,7 +230,6 @@ export const MemoizedFileItem = memo( prev.value === next.value && prev.name === next.name && prev.meta === next.meta && - prev.pinned === next.pinned && (prev.folderPath === next.folderPath || (prev.folderPath?.length === next.folderPath?.length && (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) @@ -304,32 +241,19 @@ export const MemoizedTaskItem = memo( onSelect, name, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void name: string - } & ResultAdornmentProps) { + } & ResultMetaProps) { return ( {name} {meta && } - {onTogglePin && ( - - )} ) }, - (prev, next) => - prev.value === next.value && - prev.name === next.name && - prev.meta === next.meta && - prev.pinned === next.pinned + (prev, next) => prev.value === next.value && prev.name === next.name && prev.meta === next.meta ) export const MemoizedWorkspaceItem = memo( @@ -338,29 +262,46 @@ export const MemoizedWorkspaceItem = memo( onSelect, name, isCurrent, + logoUrl, + color, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void name: string isCurrent?: boolean - } & ResultAdornmentProps) { + logoUrl?: string | null + color?: string + } & ResultMetaProps) { + const backgroundColor = + color && WORKSPACE_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)' + return ( + {logoUrl ? ( + + ) : ( + + )} {name} {isCurrent && (current)} {meta && } - {onTogglePin && ( - - )} ) }, @@ -368,8 +309,9 @@ export const MemoizedWorkspaceItem = memo( prev.value === next.value && prev.name === next.name && prev.isCurrent === next.isCurrent && - prev.meta === next.meta && - prev.pinned === next.pinned + prev.logoUrl === next.logoUrl && + prev.color === next.color && + prev.meta === next.meta ) export const MemoizedPageItem = memo( @@ -380,33 +322,18 @@ export const MemoizedPageItem = memo( name, shortcut, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void icon: ComponentType<{ className?: string }> name: string shortcut?: string - } & ResultAdornmentProps) { + } & ResultMetaProps) { return ( {name} - {meta ? ( - - ) : shortcut ? ( - - {shortcut} - - ) : null} - {onTogglePin && ( - - )} + {meta ? : shortcut ? : null} ) }, @@ -415,8 +342,7 @@ export const MemoizedPageItem = memo( prev.icon === next.icon && prev.name === next.name && prev.shortcut === next.shortcut && - prev.meta === next.meta && - prev.pinned === next.pinned + prev.meta === next.meta ) export const MemoizedIconItem = memo( @@ -426,26 +352,17 @@ export const MemoizedIconItem = memo( name, icon: Icon, meta, - pinned, - onTogglePin, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> - } & ResultAdornmentProps) { + } & ResultMetaProps) { return ( {name} {meta && } - {onTogglePin && ( - - )} ) }, @@ -453,6 +370,5 @@ export const MemoizedIconItem = memo( prev.value === next.value && prev.name === next.name && prev.icon === next.icon && - prev.meta === next.meta && - prev.pinned === next.pinned + prev.meta === next.meta ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts index 8b857485ce2..8961455f1bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts @@ -1,3 +1,4 @@ +export { CommandFadedList, CommandSearch } from './command-chrome' export { MemoizedCommandItem, MemoizedFileItem, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx new file mode 100644 index 00000000000..d92110a9bbb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx @@ -0,0 +1,159 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { Command } from 'cmdk' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SearchEntryGroup, + WorkspacesGroup, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups' +import type { + SearchEntry, + SearchEntryHandlers, + WorkspaceItem, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' + +function TestIcon() { + return +} + +const handlers: SearchEntryHandlers = { + onSelectAction: vi.fn(), + onSelectConnectedAccount: vi.fn(), + onSelectIntegration: vi.fn(), + onSelectBlock: vi.fn(), + onSelectTool: vi.fn(), + onSelectTrigger: vi.fn(), + onSelectChat: vi.fn(), + onSelectWorkflow: vi.fn(), + onSelectTable: vi.fn(), + onSelectFile: vi.fn(), + onSelectKnowledgeBase: vi.fn(), + onSelectToolOperation: vi.fn(), + onSelectWorkspace: vi.fn(), + onSelectDoc: vi.fn(), + onSelectPage: vi.fn(), +} + +const actionEntry: SearchEntry = { + section: 'actions', + score: 100, + item: { + id: 'run-workflow', + name: 'Run workflow', + icon: TestIcon, + context: 'workflow', + run: vi.fn(), + }, +} + +const workspaceItems: WorkspaceItem[] = [ + { + id: 'workspace-acme', + name: 'Acme', + href: '/workspace/workspace-acme/w', + logoUrl: 'https://cdn.example.com/acme.png', + }, + { + id: 'workspace-beta', + name: 'Beta Workspace', + href: '/workspace/workspace-beta/w', + color: '#123456', + }, +] + +const workspaceEntries: SearchEntry[] = workspaceItems.map((item, index) => ({ + section: 'workspaces', + score: 100 - index, + item, +})) + +describe('SearchEntryGroup', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + }) + + it('renders flat search results without passing a null group heading to cmdk', () => { + act(() => { + root.render( + + + + + + ) + }) + + expect(container.textContent).toContain('Run workflow') + expect(container.textContent).toContain('Workflow') + expect(container.querySelector('[cmdk-group-heading]')).toBeNull() + expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull() + }) + + it('renders workspace logos and initial fallbacks in workspace results', () => { + act(() => { + root.render( + + + + + + ) + }) + + const logo = container.querySelector('img[data-slot="workspace-icon"]') + const fallback = container.querySelector('span[data-slot="workspace-icon"]') + expect(logo?.src).toBe('https://cdn.example.com/acme.png') + expect(logo?.alt).toBe('') + expect(fallback?.textContent).toBe('B') + expect(fallback?.querySelector('rect')?.getAttribute('fill')).toBe('#123456') + }) + + it('renders workspace icons in the default workspace section', () => { + act(() => { + root.render( + + + + + + ) + }) + + expect(container.querySelector('img[data-slot="workspace-icon"]')).not.toBeNull() + expect(container.querySelector('span[data-slot="workspace-icon"]')?.textContent).toBe('B') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index 4a1d4a516bb..d505f0c94e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -28,8 +28,9 @@ import type { } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { GROUP_HEADING_CLASSNAME, + getActionGroupLabel, + getToolOperationLabel, SECTION_LABELS, - searchEntryKey, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import type { SearchBlockItem, @@ -46,7 +47,7 @@ export const ActionsGroup = memo(function ActionsGroup({ }) { if (items.length === 0) return null return ( - + {items.map((action) => ( onSelect(workspace)} name={workspace.name} isCurrent={workspace.isCurrent} + logoUrl={workspace.logoUrl} + color={workspace.color} /> ))} @@ -377,8 +380,7 @@ function createIconGroup( interface RenderEntryOptions { keyPrefix: string meta?: string - pinned: boolean - onTogglePin: () => void + search: string } function renderSearchEntry( @@ -389,8 +391,6 @@ function renderSearchEntry( const key = `${options.keyPrefix}${entry.section}-${entry.item.id}` const rowProps = { meta: options.meta, - pinned: options.pinned, - onTogglePin: options.onTogglePin, } switch (entry.section) { @@ -536,7 +536,7 @@ function renderSearchEntry( icon={entry.item.icon} bgColor={entry.item.bgColor} showColoredIcon - label={entry.item.name} + label={getToolOperationLabel(entry.item, options.search)} {...rowProps} /> ) @@ -548,6 +548,8 @@ function renderSearchEntry( onSelect={() => handlers.onSelectWorkspace(entry.item)} name={entry.item.name} isCurrent={entry.item.isCurrent} + logoUrl={entry.item.logoUrl} + color={entry.item.color} {...rowProps} /> ) @@ -580,40 +582,44 @@ function renderSearchEntry( } interface SearchEntryGroupProps { - variant: 'section' | 'topMatch' | 'favorites' + variant: 'section' | 'results' heading?: string + search?: string entries: SearchEntry[] handlers: SearchEntryHandlers - favorites: ReadonlySet - onToggleFavorite: (entry: SearchEntry) => void } /** Renders ordinary and aggregate rows with their existing section chrome. */ export const SearchEntryGroup = memo(function SearchEntryGroup({ variant, heading, + search = '', entries, handlers, - favorites, - onToggleFavorite, }: SearchEntryGroupProps) { if (entries.length === 0) return null - const aggregate = variant !== 'section' - const groupHeading = - variant === 'topMatch' ? 'Top Match' : variant === 'favorites' ? 'Favorites' : heading - const keyPrefix = aggregate ? `${variant}-` : '' + const keyPrefix = variant === 'results' ? 'results-' : '' + const renderedEntries = entries.map((entry) => + renderSearchEntry(entry, handlers, { + keyPrefix, + meta: + variant === 'results' + ? entry.section === 'actions' + ? getActionGroupLabel(entry.item) + : SECTION_LABELS[entry.section] + : undefined, + search, + }) + ) + + if (variant === 'results') { + return {renderedEntries} + } return ( - - {entries.map((entry) => - renderSearchEntry(entry, handlers, { - keyPrefix, - meta: aggregate ? SECTION_LABELS[entry.section] : undefined, - pinned: favorites.has(searchEntryKey(entry)), - onTogglePin: () => onToggleFavorite(entry), - }) - )} + + {renderedEntries} ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index e4d7906bd34..ad1c00aee3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -14,7 +14,6 @@ import { Key, Play, Plus, - Search, Send, Settings, Table, @@ -30,8 +29,13 @@ import { isChatEnabled } from '@/lib/core/config/env-flags' import { captureEvent } from '@/lib/posthog/client' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { + CommandFadedList, + CommandSearch, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups' import type { + ActionGroupLabel, ActionItem, FileItem, IntegrationSearchItem, @@ -44,13 +48,12 @@ import type { WorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { - getGlobalTopMatches, - getSectionNameMatches, + getActionGroupLabel, + getGlobalSearchResults, MAX_RESULTS_PER_GROUP, SECTION_LABELS, scoreActions, scoreSectionItems, - searchEntryKey, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { CMDK_ITEM_GAP_CLASS, @@ -59,7 +62,6 @@ import { import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useSearchFavoritesStore } from '@/stores/modals/search/favorites/store' import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, @@ -122,9 +124,6 @@ export function SearchModal({ () => SEARCH_SECTIONS.filter((section) => !sections || sections.includes(section)), [sections] ) - const favoriteKeys = useSearchFavoritesStore((state) => state.favorites) - const toggleFavorite = useSearchFavoritesStore((state) => state.toggleFavorite) - const favoriteSet = useMemo(() => new Set(favoriteKeys), [favoriteKeys]) const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) @@ -172,7 +171,7 @@ export function SearchModal({ name: 'Logs', icon: Library, href: `/workspace/${workspaceId}/logs`, - shortcut: '⌘⇧L', + shortcut: '⇧⌘L', }, { id: 'secrets', @@ -265,7 +264,7 @@ export function SearchModal({ name: 'Fit workflow to view', keywords: 'zoom center recenter canvas reset', icon: Scan, - shortcut: '⌘⇧F', + shortcut: '⇧⌘F', context: 'workflow', run: () => invokeCommand('fit-to-view'), }) @@ -581,10 +580,7 @@ export function SearchModal({ ) => { if (!visibleSections.has(section)) return [] return query - ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra).slice( - 0, - MAX_RESULTS_PER_GROUP - ) + ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra, MAX_RESULTS_PER_GROUP) : items.map((item) => ({ item, score: 0 })) } const availableActions = actions.filter( @@ -593,10 +589,21 @@ export function SearchModal({ (action.context === 'workflow' && isOnWorkflowPage) || (action.context === 'integrations' && isOnIntegrationsPage) ) + const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => + query + ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) + : items.map((item) => ({ item, score: 0 })) const rankedActions = visibleSections.has('actions') - ? query - ? scoreActions(availableActions, deferredSearch) - : availableActions.map((item) => ({ item, score: 0 })) + ? [ + ...rankActionGroup( + availableActions.filter((action) => getActionGroupLabel(action) === 'Workflow'), + 'Workflow' + ), + ...rankActionGroup( + availableActions.filter((action) => getActionGroupLabel(action) === 'Platform'), + 'Platform' + ), + ] : [] const availableBlocks = isOnWorkflowPage ? blocks.filter( @@ -608,8 +615,9 @@ export function SearchModal({ (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId ) : [] - const rankedIntegrations = - isOnIntegrationsPage && query ? rank('integrations', integrations, (item) => item.name) : [] + const rankedIntegrations = isOnIntegrationsPage + ? rank('integrations', integrations, (item) => item.name) + : [] return { actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), @@ -711,44 +719,34 @@ export function SearchModal({ pages, ]) - const { matchingSectionGroups, remainingSectionGroups } = useMemo(() => { - const matchingSections = getSectionNameMatches(displaySections, deferredSearch) - const matchingSet = new Set(matchingSections) - const toGroup = (section: SearchSection) => ({ - section, - entries: entriesBySection[section], - }) - return { - matchingSectionGroups: matchingSections.map(toGroup), - remainingSectionGroups: displaySections - .filter((section) => !matchingSet.has(section)) - .map(toGroup), - } - }, [deferredSearch, displaySections, entriesBySection]) - - const globalTopMatches = useMemo( - () => (deferredSearch.trim() ? getGlobalTopMatches(entriesBySection, displaySections) : []), - [deferredSearch, entriesBySection, displaySections] + const isSearching = Boolean(deferredSearch.trim()) + const searchResults = useMemo( + () => (isSearching ? getGlobalSearchResults(entriesBySection, displaySections) : []), + [displaySections, entriesBySection, isSearching] ) + const sectionGroups = useMemo( + () => + displaySections.flatMap((section) => { + const entries = entriesBySection[section] + if (section !== 'actions') { + return [{ key: section, heading: SECTION_LABELS[section], entries }] + } - const favoriteEntries = useMemo((): SearchEntry[] => { - const entriesByKey = new Map() - for (const section of displaySections) { - for (const entry of entriesBySection[section]) { - entriesByKey.set(searchEntryKey(entry), entry) - } - } - const entries: SearchEntry[] = [] - for (const key of favoriteKeys) { - const entry = entriesByKey.get(key) - if (entry) entries.push(entry) - } - return deferredSearch.trim() ? entries.slice(0, MAX_RESULTS_PER_GROUP) : entries - }, [favoriteKeys, displaySections, entriesBySection, deferredSearch]) + const platformEntries = entries.filter( + (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === 'Platform' + ) + const workflowEntries = entries.filter( + (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === 'Workflow' + ) - const handleToggleFavorite = useCallback( - (entry: SearchEntry) => toggleFavorite(searchEntryKey(entry)), - [toggleFavorite] + return [ + ...(isOnWorkflowPage + ? [{ key: 'workflow-actions', heading: 'Workflow', entries: workflowEntries }] + : []), + { key: 'platform-actions', heading: 'Platform', entries: platformEntries }, + ] + }), + [displaySections, entriesBySection, isOnWorkflowPage] ) const entryHandlers = useMemo( @@ -817,69 +815,49 @@ export function SearchModal({ }} >
- -
- - +
+ + + No results found. + + + {isSearching ? ( + + ) : ( + sectionGroups.map(({ key, heading, entries }) => ( + + )) + )} + +
- - - No results found. - - - {globalTopMatches.length > 0 && ( - - )} - {matchingSectionGroups.map(({ section, entries }) => ( - - ))} - {favoriteEntries.length > 0 && ( - - )} - {remainingSectionGroups.map(({ section, entries }) => ( - - ))} -
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 455079ba5aa..b6b5e711327 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -6,8 +6,9 @@ import { filterAndCap, filterAndSort, fuzzyMatch, - getGlobalTopMatches, - getSectionNameMatches, + getActionGroupLabel, + getGlobalSearchResults, + getToolOperationLabel, MAX_RESULTS_PER_GROUP, type SearchEntry, scoreActions, @@ -15,7 +16,33 @@ import { scoreSectionItems, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -describe('getGlobalTopMatches', () => { +describe('getActionGroupLabel', () => { + const action = { + id: 'test-action', + name: 'Test action', + icon: () => null, + run: () => {}, + } + + it('separates workflow actions from platform actions', () => { + expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Workflow') + expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Platform') + expect(getActionGroupLabel({ ...action, context: 'integrations' })).toBe('Platform') + }) + + it('lets an action group label surface actions whose names do not match', () => { + const workflowAction = { + ...action, + name: 'Fit canvas to view', + context: 'workflow' as const, + } + + expect(scoreActions([workflowAction], 'workflow', 50, 'Workflow')).toHaveLength(1) + expect(scoreActions([workflowAction], 'platform', 50, 'Workflow')).toHaveLength(0) + }) +}) + +describe('getGlobalSearchResults', () => { it('merge-ranks results across every visible section', () => { const action: SearchEntry = { section: 'actions', @@ -39,7 +66,7 @@ describe('getGlobalTopMatches', () => { item: { id: 'chat-1', name: 'New chat', href: '/chat-1' }, } - const matches = getGlobalTopMatches( + const matches = getGlobalSearchResults( { actions: [action], workflows: [workflow], chats: [chat] }, ['actions', 'workflows', 'chats'] ) @@ -62,7 +89,7 @@ describe('getGlobalTopMatches', () => { expect(actionMatch.score).toBe(chatMatch.score) expect( - getGlobalTopMatches( + getGlobalSearchResults( { actions: [{ section: 'actions', ...actionMatch }], chats: [{ section: 'chats', ...chatMatch }], @@ -72,31 +99,86 @@ describe('getGlobalTopMatches', () => { ).toEqual(['new-chat-action', 'new-chat-result']) }) - it('keeps only the five highest-scoring entries', () => { + it('keeps every matching entry in score order', () => { const workflows: SearchEntry[] = Array.from({ length: 8 }, (_, index) => ({ section: 'workflows', score: index, item: { id: `workflow-${index}`, name: `Workflow ${index}`, href: `/workflow-${index}` }, })) - expect(getGlobalTopMatches({ workflows }, ['workflows']).map((entry) => entry.item.id)).toEqual( - ['workflow-7', 'workflow-6', 'workflow-5', 'workflow-4', 'workflow-3'] - ) + expect( + getGlobalSearchResults({ workflows }, ['workflows']).map((entry) => entry.item.id) + ).toEqual([ + 'workflow-7', + 'workflow-6', + 'workflow-5', + 'workflow-4', + 'workflow-3', + 'workflow-2', + 'workflow-1', + 'workflow-0', + ]) + }) + + it('places selected docs after selected tool operations without changing the result set', () => { + const Icon = () => null + const tool: SearchEntry = { + section: 'tools', + score: 100, + item: { id: 'whatsapp', name: 'WhatsApp', icon: Icon, bgColor: '#25D366', type: 'whatsapp' }, + } + const docs: SearchEntry = { + section: 'docs', + score: 90, + item: { id: 'docs-whatsapp', name: 'WhatsApp', icon: Icon, href: '/whatsapp' }, + } + const operation: SearchEntry = { + section: 'toolOperations', + score: 80, + item: { + id: 'whatsapp_upload_media', + name: 'Upload Media', + serviceName: 'WhatsApp', + searchValue: 'WhatsApp Upload Media', + icon: Icon, + bgColor: '#25D366', + blockType: 'whatsapp', + operationId: 'upload_media', + }, + } + + expect( + getGlobalSearchResults({ tools: [tool], docs: [docs], toolOperations: [operation] }, [ + 'tools', + 'docs', + 'toolOperations', + ]).map((entry) => entry.item.id) + ).toEqual(['whatsapp', 'whatsapp_upload_media', 'docs-whatsapp']) }) }) -describe('getSectionNameMatches', () => { - const sections = ['actions', 'workflows', 'workspaces', 'chats', 'pages'] as const +describe('getToolOperationLabel', () => { + const Icon = () => null + const operation = { + id: 'whatsapp_send_message', + name: 'Send Message', + serviceName: 'WhatsApp', + searchValue: 'WhatsApp Send Message', + icon: Icon, + bgColor: '#25D366', + blockType: 'whatsapp', + operationId: 'send_message', + } - it('promotes exact and partial section-name matches', () => { - expect(getSectionNameMatches(sections, 'Workspaces')).toEqual(['workspaces']) - expect(getSectionNameMatches(sections, 'chat')).toEqual(['chats']) - expect(getSectionNameMatches(sections, 'work')).toEqual(['workflows', 'workspaces']) + it('adds the integration breadcrumb when the integration name matched', () => { + expect(getToolOperationLabel(operation, 'WhatsApp')).toBe('WhatsApp · Send Message') + expect(getToolOperationLabel(operation, 'whats send')).toBe('WhatsApp · Send Message') }) - it('does not change section priority for non-section or empty queries', () => { - expect(getSectionNameMatches(sections, 'settings')).toEqual([]) - expect(getSectionNameMatches(sections, '')).toEqual([]) + it('keeps the bare operation name for direct operation-name matches', () => { + expect(getToolOperationLabel(operation, 'Send Message')).toBe('Send Message') + expect(getToolOperationLabel(operation, 'message')).toBe('Send Message') + expect(getToolOperationLabel(operation, '')).toBe('Send Message') }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index f063de1ceb9..0ddf91fd4e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -37,6 +37,8 @@ export interface WorkspaceItem { name: string href: string isCurrent?: boolean + logoUrl?: string | null + color?: string } export interface PageItem { @@ -71,6 +73,13 @@ export interface ActionItem { run: () => void } +export type ActionGroupLabel = 'Platform' | 'Workflow' + +/** Presentation group for an action without changing its stable result identity. */ +export function getActionGroupLabel(action: ActionItem): ActionGroupLabel { + return action.context === 'workflow' ? 'Workflow' : 'Platform' +} + export interface SearchModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -106,14 +115,10 @@ export interface CommandItemProps { label: string /** Right-aligned source section shown in aggregate result groups. */ meta?: string - /** Whether this result is pinned. */ - pinned?: boolean - /** Toggles this result's pinned state. */ - onTogglePin?: () => void } export const SECTION_LABELS: Record = { - actions: 'Actions', + actions: 'Platform', connectedAccounts: 'Connected', integrations: 'Integrations', blocks: 'Blocks', @@ -130,8 +135,6 @@ export const SECTION_LABELS: Record = { pages: 'Pages', } -export const TOP_MATCH_COUNT = 5 - export type SearchEntry = | { section: 'actions'; score: number; item: ActionItem } | { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem } @@ -163,18 +166,13 @@ export interface SearchEntryHandlers { onSelectPage: (item: PageItem) => void } -/** Stable, section-qualified identity used by the persisted favorites store. */ -export function searchEntryKey(entry: SearchEntry): string { - return `${entry.section}:${entry.item.id}` -} - -/** Merge-ranks visible sections into the five highest-scoring results. */ -export function getGlobalTopMatches( +/** Merge-ranks every match from the visible sections into one flat result list. */ +export function getGlobalSearchResults( entriesBySection: Partial>, sections: readonly SearchSection[] ): SearchEntry[] { const sectionOrder = new Map(sections.map((section, index) => [section, index])) - const topMatches: Array<{ entry: SearchEntry; originalIndex: number }> = [] + const rankedMatches: Array<{ entry: SearchEntry; originalIndex: number }> = [] let originalIndex = 0 const compare = ( @@ -188,30 +186,25 @@ export function getGlobalTopMatches( for (const section of sections) { for (const entry of entriesBySection[section] ?? []) { - const candidate = { entry, originalIndex } + rankedMatches.push({ entry, originalIndex }) originalIndex += 1 - const insertionIndex = topMatches.findIndex((current) => compare(candidate, current) < 0) - if (insertionIndex === -1) { - if (topMatches.length < TOP_MATCH_COUNT) topMatches.push(candidate) - continue - } - topMatches.splice(insertionIndex, 0, candidate) - if (topMatches.length > TOP_MATCH_COUNT) topMatches.pop() } } - return topMatches.map(({ entry }) => entry) -} - -/** Returns visible sections whose heading matches the query, strongest first. */ -export function getSectionNameMatches( - sections: readonly SearchSection[], - search: string -): SearchSection[] { - if (!search.trim()) return [] - return scoreAndSort([...sections], (section) => SECTION_LABELS[section], search).map( - ({ item }) => item - ) + rankedMatches.sort(compare) + const matches = rankedMatches.map(({ entry }) => entry) + const integrationDetails = [ + ...matches.filter((entry) => entry.section === 'toolOperations'), + ...matches.filter((entry) => entry.section === 'docs'), + ] + let integrationDetailIndex = 0 + + return matches.map((entry) => { + if (entry.section !== 'toolOperations' && entry.section !== 'docs') return entry + const orderedEntry = integrationDetails[integrationDetailIndex] + integrationDetailIndex += 1 + return orderedEntry + }) } export const GROUP_HEADING_CLASSNAME = @@ -368,11 +361,12 @@ const NAME_MATCH_TIER = 1_000_000 * an exact name hit isn't diluted by a long secondary string ("Agent" beats * "Pi Coding Agent" for the query "agent"). */ -function scoreItem(name: string, extra: string | undefined, search: string): FuzzyResult { +function scoreItem(name: string, search: string, getExtra?: () => string | undefined): FuzzyResult { const byName = fuzzyMatch(name, search) if (byName.matched) { return { matched: true, score: byName.score + NAME_MATCH_TIER, positions: byName.positions } } + const extra = getExtra?.() if (!extra) return NO_MATCH const byExtra = fuzzyMatch(extra, search) return byExtra.matched ? byExtra : NO_MATCH @@ -388,27 +382,51 @@ export function scoreAndSort( const query = search.trim() const scored: Array<{ item: T; score: number }> = [] for (const item of items) { - const { matched, score } = scoreItem(toValue(item), toExtra?.(item), query) + const { matched, score } = scoreItem( + toValue(item), + query, + toExtra ? () => toExtra(item) : undefined + ) if (matched) scored.push({ item, score }) } scored.sort((a, b) => b.score - a.score) return scored } +function matchesIntegrationQuery(integrationName: string, search: string): boolean { + const query = search.trim() + if (!query) return false + + return query.split(/\s+/).some((term) => fuzzyMatch(integrationName, term).matched) +} + +/** Adds integration context only when it, rather than the operation name, matched the query. */ +export function getToolOperationLabel(operation: SearchToolOperationItem, search: string): string { + const query = search.trim() + if (!query || fuzzyMatch(operation.name, query).matched) return operation.name + + return matchesIntegrationQuery(operation.serviceName, query) + ? `${operation.serviceName} · ${operation.name}` + : operation.name +} + /** * Scores normal item matches first, then fills a matched section with its * remaining rows in natural order. */ -export function scoreSectionItems( - section: SearchSection, +function scoreItemsForSection( + sectionLabel: string, items: T[], toValue: (item: T) => string, search: string, - toExtra?: (item: T) => string | undefined + toExtra?: (item: T) => string | undefined, + maxResults = Number.POSITIVE_INFINITY ): Array<{ item: T; score: number }> { const rankedItems = scoreAndSort(items, toValue, search, toExtra) - const sectionMatch = fuzzyMatch(SECTION_LABELS[section], search.trim()) - if (!sectionMatch.matched) return rankedItems + const sectionMatch = fuzzyMatch(sectionLabel, search.trim()) + if (!sectionMatch.matched || rankedItems.length >= maxResults) { + return rankedItems.slice(0, maxResults) + } const matchedItems = new Set(rankedItems.map(({ item }) => item)) const lowestItemScore = rankedItems.at(-1)?.score @@ -417,25 +435,39 @@ export function scoreSectionItems( ? sectionMatch.score : Math.min(sectionMatch.score, lowestItemScore - 1) - return [ - ...rankedItems, - ...items - .filter((item) => !matchedItems.has(item)) - .map((item) => ({ item, score: fallbackScore })), - ] + const results = [...rankedItems] + for (const item of items) { + if (!matchedItems.has(item)) results.push({ item, score: fallbackScore }) + if (results.length >= maxResults) break + } + return results +} + +export function scoreSectionItems( + section: SearchSection, + items: T[], + toValue: (item: T) => string, + search: string, + toExtra?: (item: T) => string | undefined, + maxResults = Number.POSITIVE_INFINITY +): Array<{ item: T; score: number }> { + return scoreItemsForSection(SECTION_LABELS[section], items, toValue, search, toExtra, maxResults) } /** Scores actions by visible name before falling back to their keywords. */ export function scoreActions( actions: ActionItem[], - search: string + search: string, + maxResults = Number.POSITIVE_INFINITY, + groupLabel: ActionGroupLabel = 'Platform' ): Array<{ item: ActionItem; score: number }> { - return scoreSectionItems( - 'actions', + return scoreItemsForSection( + groupLabel, actions, (action) => action.name, search, - (action) => `${action.name} ${action.keywords ?? ''}` + (action) => `${action.name} ${action.keywords ?? ''}`, + maxResults ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 210239db68a..dd44efc80fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -771,6 +771,8 @@ export const Sidebar = memo(function Sidebar({ name: workspace.name, href: `/workspace/${workspace.id}/w`, isCurrent: workspace.id === workspaceId, + logoUrl: workspace.logoUrl, + color: workspace.color, })), [workspaces, workspaceId] ) @@ -1297,7 +1299,8 @@ export const Sidebar = memo(function Sidebar({ { id: 'open-search', handler: () => { - openSearchModal() + const searchModal = useSearchModalStore.getState() + searchModal.setOpen(!searchModal.isOpen) }, }, { diff --git a/apps/sim/stores/modals/search/favorites/store.test.ts b/apps/sim/stores/modals/search/favorites/store.test.ts deleted file mode 100644 index 939dd88c822..00000000000 --- a/apps/sim/stores/modals/search/favorites/store.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { beforeEach, describe, expect, it } from 'vitest' -import { useSearchFavoritesStore } from '@/stores/modals/search/favorites/store' - -describe('useSearchFavoritesStore', () => { - beforeEach(() => { - localStorage.clear() - useSearchFavoritesStore.getState().reset() - }) - - it('pins and unpins section-qualified entries in selection order', () => { - const store = useSearchFavoritesStore.getState() - - store.toggleFavorite('chats:chat-1') - store.toggleFavorite('workflows:workflow-1') - expect(useSearchFavoritesStore.getState().favorites).toEqual([ - 'chats:chat-1', - 'workflows:workflow-1', - ]) - - useSearchFavoritesStore.getState().toggleFavorite('chats:chat-1') - expect(useSearchFavoritesStore.getState().favorites).toEqual(['workflows:workflow-1']) - }) - - it('resets all pinned entries', () => { - useSearchFavoritesStore.getState().toggleFavorite('pages:settings') - useSearchFavoritesStore.getState().reset() - - expect(useSearchFavoritesStore.getState().favorites).toEqual([]) - }) - - it('rehydrates pinned entries from local storage', async () => { - useSearchFavoritesStore.setState({ favorites: [] }) - localStorage.setItem( - 'search-favorites', - JSON.stringify({ state: { favorites: ['chats:chat-1'] }, version: 0 }) - ) - - await useSearchFavoritesStore.persist.rehydrate() - - expect(useSearchFavoritesStore.getState().favorites).toEqual(['chats:chat-1']) - }) -}) diff --git a/apps/sim/stores/modals/search/favorites/store.ts b/apps/sim/stores/modals/search/favorites/store.ts deleted file mode 100644 index aea3664e9af..00000000000 --- a/apps/sim/stores/modals/search/favorites/store.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { create } from 'zustand' -import { devtools, persist } from 'zustand/middleware' - -export interface SearchFavoritesState { - favorites: string[] - toggleFavorite: (key: string) => void - reset: () => void -} - -const initialState = { favorites: [] as string[] } - -export const useSearchFavoritesStore = create()( - devtools( - persist( - (set) => ({ - ...initialState, - toggleFavorite: (key) => - set((state) => ({ - favorites: state.favorites.includes(key) - ? state.favorites.filter((favorite) => favorite !== key) - : [...state.favorites, key], - })), - reset: () => set((state) => (state.favorites.length === 0 ? state : initialState)), - }), - { - name: 'search-favorites', - partialize: (state) => ({ favorites: state.favorites }), - } - ), - { name: 'search-favorites-store' } - ) -) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index f21d516ddc4..0ed810a8b69 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -180,6 +180,7 @@ export const useSearchModalStore = create()( return { id: op.id, name: op.operationName, + serviceName: op.serviceName, searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`, icon: op.icon, bgColor: op.bgColor, diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index c3e8feb50f6..4b876a02a13 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -22,6 +22,7 @@ export interface SearchBlockItem { export interface SearchToolOperationItem { id: string name: string + serviceName: string searchValue: string icon: ComponentType<{ className?: string }> bgColor: string @@ -60,15 +61,14 @@ export const SEARCH_SECTIONS = [ 'actions', 'connectedAccounts', 'integrations', - 'blocks', - 'tools', 'triggers', - // Resource groups follow the sidebar's top-down order. 'chats', + 'workflows', 'tables', 'files', 'knowledgeBases', - 'workflows', + 'blocks', + 'tools', 'toolOperations', 'workspaces', 'docs', From 1ce85cadad2023458d6a63359c0c0a84a8a597f5 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:58:17 -0700 Subject: [PATCH 11/29] feat(search): unify cmd+k into one page-aware section model Every palette view now derives from a single rule: the page's action group, its own entity section hoisted, Platform actions, then a fixed tail shared by all pages. Adds page commands for table/file/KB details, logs, and deploy; a Logs section with run dates; chat last-activity receipts; kebab-cased secondary search text with per-entry scattered matching; exact-section-name ranking lifts; and scroll/selection fixes on open, loop, and arrow navigation. Removes the canvas block/tool/trigger/docs sections and the store's unused section restriction and pending-connect plumbing. Co-Authored-By: Claude Fable 5 --- .../workspace/[workspaceId]/files/files.tsx | 11 + .../app/workspace/[workspaceId]/home/home.tsx | 206 ++--- .../[workspaceId]/home/hooks/index.ts | 1 + .../hooks/use-mothership-handoff.test.tsx | 75 ++ .../home/hooks/use-mothership-handoff.ts | 41 + .../[workspaceId]/home/search-params.ts | 17 + .../[workspaceId]/knowledge/[id]/base.tsx | 9 + .../[workspaceId]/knowledge/knowledge.tsx | 255 +++---- .../app/workspace/[workspaceId]/logs/logs.tsx | 8 + .../providers/global-commands-provider.tsx | 8 +- .../scheduled-tasks/scheduled-tasks.tsx | 236 ++++++ .../[workspaceId]/tables/[tableId]/table.tsx | 7 + .../workspace/[workspaceId]/tables/tables.tsx | 7 + .../panel/components/deploy/deploy.tsx | 3 + .../command-chrome/command-chrome.test.tsx | 19 +- .../command-chrome/command-chrome.tsx | 8 +- .../command-items/command-items.tsx | 79 +- .../search-modal/components/index.ts | 19 +- .../components/search-groups/index.ts | 19 +- .../search-groups/search-groups.test.tsx | 19 +- .../search-groups/search-groups.tsx | 418 +--------- .../search-modal/search-modal.test.tsx | 305 ++++++++ .../components/search-modal/search-modal.tsx | 719 ++++++++++++------ .../components/search-modal/utils.test.ts | 199 +++-- .../sidebar/components/search-modal/utils.ts | 291 ++++--- .../w/components/sidebar/sidebar.tsx | 484 +++++++----- apps/sim/lib/posthog/events.ts | 5 +- apps/sim/lib/search/tokens.ts | 13 + apps/sim/stores/modals/search/store.test.ts | 4 +- apps/sim/stores/modals/search/store.ts | 27 +- apps/sim/stores/modals/search/types.ts | 66 +- ...ontext-aware-command-palette-ideation.html | 536 +++++++++++++ 32 files changed, 2642 insertions(+), 1472 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx create mode 100644 apps/sim/lib/search/tokens.ts create mode 100644 docs/ideation/2026-08-04-context-aware-command-palette-ideation.html diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index a79347de310..a269e063539 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -103,6 +103,7 @@ import { isUntitledName, uniqueMarkdownName, } from '@/app/workspace/[workspaceId]/files/untitled-title' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' @@ -1681,6 +1682,16 @@ export function Files() { fileInputRef.current?.click() }, [canEdit, uploading]) + useRegisterGlobalCommands(() => [ + { id: 'files-upload', handler: () => handleUploadClick() }, + { id: 'files-new-file', handler: () => void handleCreateFile() }, + { id: 'files-new-folder', handler: () => void handleCreateFolder() }, + { id: 'file-download', handler: () => handleDownloadSelected() }, + { id: 'file-rename', handler: () => handleStartHeaderRename() }, + { id: 'file-share', handler: () => handleShareSelected() }, + { id: 'file-delete', handler: () => handleDeleteSelected() }, + ]) + const searchConfig: SearchConfig = { value: urlSearchTerm, onChange: setSearchTerm, diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 1e6ef10d6ef..af79ad47245 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -10,33 +10,27 @@ import { useMemo, useRef, useState, - useSyncExternalStore, } from 'react' -import { Button, cn, toast } from '@sim/emcn' +import { Button, cn } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, - MothershipHandoffStorage, } from '@/lib/core/utils/browser-storage' -import { isDesktopApp } from '@/lib/desktop' import { - addMothershipContexts, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' -import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' -import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' import { @@ -44,7 +38,7 @@ import { useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' -import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -56,17 +50,15 @@ import { UserInput, type UserInputHandle, } from './components' -import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' -import type { - FileAttachmentForApi, - MothershipResource, - MothershipResourceType, - WorkspaceResourceRef, -} from './types' +import { + getMothershipUseChatOptions, + useChat, + useMothershipHandoff, + useMothershipResize, +} from './hooks' +import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types' const logger = createLogger('Home') -const subscribeToDesktopApp = () => () => {} -const getServerDesktopAppSnapshot = () => false /** * The resource preview panel pulls in the file-viewer stack (rich-markdown @@ -89,14 +81,8 @@ interface HomeProps { export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) { useOAuthReturnRouter() - const isDesktop = useSyncExternalStore( - subscribeToDesktopApp, - isDesktopApp, - getServerDesktopAppSnapshot - ) const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() - const queryClient = useQueryClient() /** * URL is the single source of truth for the selected resource. `Home` renders * client-side, so nuqs reads `?resource=` from the URL on mount — the same @@ -210,11 +196,18 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId) const { mutate: markRead } = useMarkMothershipChatRead(workspaceId) + const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize() + const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) const [skipResourceTransition, setSkipResourceTransition] = useState(false) const isResourceCollapsedRef = useRef(isResourceCollapsed) isResourceCollapsedRef.current = isResourceCollapsed + const collapseResource = useCallback(() => { + clearWidth() + setIsResourceCollapsed(true) + }, [clearWidth]) + function handleResourceEvent() { if (isResourceCollapsedRef.current) { setIsResourceCollapsed(false) @@ -228,7 +221,6 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) sendMessage, stopGeneration, resolvedChatId, - desktopScopeId, resources, activeResourceId, setActiveResourceId, @@ -262,12 +254,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) }) ) - const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize(desktopScopeId) - - const collapseResource = useCallback(() => { - clearWidth() - setIsResourceCollapsed(true) - }, [clearWidth]) + useMothershipHandoff({ chatId, workspaceId, sendMessage }) useEffect(() => { wasSendingRef.current = false @@ -394,101 +381,60 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null case 'table': return context.tableId ? { type: 'table', id: context.tableId } : null - case 'table_selection': - return context.tableId ? { type: 'table', id: context.tableId } : null case 'file': return context.fileId ? { type: 'file', id: context.fileId } : null - case 'file_selection': - return context.fileId ? { type: 'file', id: context.fileId } : null default: return null } } - /** - * Tab title for the resource a chip opens. A selection chip's label describes - * the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the - * whole file/table, so title it from the resource name the context carries. - */ - function resourceTitleForContext(context: ChatContext): string { - if (context.kind === 'file_selection') return context.fileName - if (context.kind === 'table_selection') return context.tableName - return context.label - } - function handleContextAdd(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (resolved) { - addResource({ ...resolved, title: resourceTitleForContext(context) }) + addResource({ ...resolved, title: context.label }) handleResourceEvent() } } - function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) { + function handleInitialContextRemove(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (!resolved) return - // A whole-file chip and one or more of its selection chips (or several - // selections of the same file/table) all resolve to the same resource tab. - // Only close the tab once no remaining chip still references it, so removing - // one of several chips doesn't yank a slideover the others still point at. - const stillReferenced = remaining.some((other) => { - const otherResolved = resolveResourceFromContext(other) - return otherResolved?.type === resolved.type && otherResolved.id === resolved.id - }) - if (stillReferenced) return removeResource(resolved.type, resolved.id) } - function openWorkspaceResource(resource: MothershipResource) { - const wasAdded = addResource(resource) + const resolveFileResource = useCallback( + (resource: MothershipResource): MothershipResource => { + if (resource.type !== 'file') return resource + + const reference = (resource.path || resource.id).trim() + + const file = workspaceFiles.find((candidate) => { + const candidatePath = canonicalWorkspaceFilePath({ + folderPath: candidate.folderPath, + name: candidate.name, + }) + return candidate.id === reference || candidatePath === reference + }) + + if (!file) return resource + return { + ...resource, + id: file.id, + title: resource.title || file.name, + } + }, + [workspaceFiles] + ) + + function handleWorkspaceResourceSelect(resource: MothershipResource) { + const resolvedResource = resolveFileResource(resource) + const wasAdded = addResource(resolvedResource) if (!wasAdded) { - setActiveResourceId(resource.id) + setActiveResourceId(resolvedResource.id) } handleResourceEvent() } - /** - * Opens the resource a message chip points at, resolving it first. A chip may - * carry only a filename — the agent names a file before the client's file - * list knows it exists — so one forced refetch closes that window. What still - * resolves to nothing opens nothing, rather than a tab that cannot be - * viewed or removed. - */ - async function handleWorkspaceResourceSelect(ref: WorkspaceResourceRef) { - const immediate = resolveWorkspaceResourceRef(ref, workspaceFiles) - if (immediate) { - openWorkspaceResource(immediate) - return - } - if (ref.type !== 'file') return - - // `staleTime: 0` forces the fetch this branch exists for — the cached list - // is what already failed to resolve. `fetchQuery` rejects on error and this - // handler is invoked as a void callback, so failure becomes null rather - // than an unhandled rejection — and stays distinct from an empty list, so - // "we could not look" is never reported as "it is not there". - const files = await queryClient - .fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 }) - .catch(() => null) - const resolved = files && resolveWorkspaceResourceRef(ref, files) - if (resolved) { - openWorkspaceResource(resolved) - return - } - // The chip looks clickable, so refusing silently reads as a broken button. - toast.error( - files - ? `Couldn't find "${ref.title}" in this workspace` - : `Couldn't open "${ref.title}" — check your connection and try again` - ) - logger.warn('Ignored a resource chip that did not resolve', { - type: ref.type, - title: ref.title, - hasPath: Boolean(ref.path), - reachedWorkspace: files !== null, - }) - } - const hasMessages = messages.length > 0 const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}` @@ -500,16 +446,15 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const showEmptyState = !hasMessages && !showChatSkeleton return ( -
-
+
+
+ {/* Clears the expand button when the panel is closed and that button is + occupying the same corner. */} {showEmptyState && (
@@ -519,10 +464,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
-

+

What should we get done{firstName ? `, ${firstName}` : ''}?

-
+
- {isDesktop ? ( -
+ {isResourceCollapsed && ( +
- ) : ( - isResourceCollapsed && ( -
- -
- ) )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index 995df519868..427d89f0642 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -3,4 +3,5 @@ export { getWorkflowCopilotUseChatOptions, useChat, } from './use-chat' +export { useMothershipHandoff } from './use-mothership-handoff' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx new file mode 100644 index 00000000000..36124776a86 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx @@ -0,0 +1,75 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { useMothershipHandoff } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff' + +const { mockQueryState } = vi.hoisted(() => ({ + mockQueryState: { + value: null as string | null, + setValue: vi.fn(), + }, +})) + +vi.mock('nuqs', () => ({ + useQueryState: () => [mockQueryState.value, mockQueryState.setValue], +})) + +const mockSendMessage = vi.fn(async () => {}) + +function TestHarness({ renderKey }: { renderKey: number }) { + useMothershipHandoff({ + workspaceId: 'workspace-1', + sendMessage: mockSendMessage, + }) + return {renderKey} +} + +describe('useMothershipHandoff', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + localStorage.clear() + mockQueryState.value = null + mockQueryState.setValue.mockClear() + mockSendMessage.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('consumes a handoff on the initial Home mount', async () => { + MothershipHandoffStorage.store({ message: 'initial prompt' }, 'workspace-1') + + await act(async () => { + root.render() + }) + + expect(mockSendMessage).toHaveBeenCalledWith('initial prompt', undefined, undefined) + }) + + it('consumes a handoff when a cached Home route receives the URL signal', async () => { + await act(async () => { + root.render() + }) + MothershipHandoffStorage.store({ message: 'cached route prompt' }, 'workspace-1') + mockQueryState.value = '1' + + await act(async () => { + root.render() + }) + + expect(mockSendMessage).toHaveBeenCalledWith('cached route prompt', undefined, undefined) + expect(mockQueryState.setValue).toHaveBeenCalledWith(null) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts new file mode 100644 index 00000000000..423650905ba --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts @@ -0,0 +1,41 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useQueryState } from 'nuqs' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import type { UseChatReturn } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import { + mothershipHandoffParam, + mothershipHandoffUrlKeys, +} from '@/app/workspace/[workspaceId]/home/search-params' + +interface UseMothershipHandoffProps { + chatId?: string + workspaceId: string + sendMessage: UseChatReturn['sendMessage'] +} + +/** Consumes fresh-chat handoffs on first mount and cached-route reactivation. */ +export function useMothershipHandoff({ + chatId, + workspaceId, + sendMessage, +}: UseMothershipHandoffProps): void { + const [handoffSignal, setHandoffSignal] = useQueryState(mothershipHandoffParam.key, { + ...mothershipHandoffParam.parser, + ...mothershipHandoffUrlKeys, + }) + const hasCheckedInitialHandoffRef = useRef(false) + + useEffect(() => { + const shouldCheck = !hasCheckedInitialHandoffRef.current || Boolean(handoffSignal) + if (!shouldCheck) return + + hasCheckedInitialHandoffRef.current = true + if (handoffSignal) void setHandoffSignal(null) + if (chatId) return + + const handoff = MothershipHandoffStorage.consume(workspaceId) + if (handoff) void sendMessage(handoff.message, undefined, handoff.contexts) + }, [chatId, handoffSignal, sendMessage, setHandoffSignal, workspaceId]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts index ef850466f69..26e67c9c5b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -25,3 +25,20 @@ export const resourceUrlKeys = { history: 'replace', clearOnDefault: true, } as const + +/** Signals that a cached Home surface should consume a pending one-shot chat handoff. */ +export const mothershipHandoffParam = { + key: 'handoff', + parser: parseAsString, +} as const + +/** Removes the transient handoff signal without adding a browser-history entry. */ +export const mothershipHandoffUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const + +/** Builds a fresh-chat URL that wakes the handoff consumer without exposing prompt text. */ +export function getMothershipHandoffHref(workspaceId: string): string { + return `/workspace/${workspaceId}/home?${mothershipHandoffParam.key}=1` +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 651f69c4074..3d71ef5e63b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -77,6 +77,7 @@ import { pageUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -707,6 +708,14 @@ export function KnowledgeBase({ setShowAddDocumentsModal(true) } + useRegisterGlobalCommands(() => [ + { id: 'knowledge-base-new-documents', handler: () => setShowAddDocumentsModal(true) }, + { id: 'knowledge-base-new-connector', handler: () => setShowAddConnectorModal(true) }, + { id: 'knowledge-base-rename', handler: () => kbRename.startRename(id, knowledgeBaseName) }, + { id: 'knowledge-base-tags', handler: () => setShowTagsModal(true) }, + { id: 'knowledge-base-delete', handler: () => setShowDeleteDialog(true) }, + ]) + /** * Handles bulk enabling of selected documents */ diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 4be2d30d5a0..69807aedf3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' import { Button, ChipConfirmModal, ChipDropdown, Plus, Tooltip, toast } from '@sim/emcn' -import { Database, FolderPlus, Pencil, Trash } from '@sim/emcn/icons' +import { Database, FolderPlus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' @@ -26,14 +26,10 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' -import type { - MoveOptionNode, - SortableResource, -} from '@/app/workspace/[workspaceId]/components/folders' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { buildDescendantIndex, buildMoveOptions, - FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, folderRow, @@ -41,7 +37,6 @@ import { nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, - sortResources, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' @@ -58,7 +53,8 @@ import { knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' -import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' +import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -80,11 +76,6 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { docCount?: number } -/** A list row, resolved to the entity it refers to. */ -type KnowledgeResourceItem = - | { kind: 'base'; base: KnowledgeBaseWithDocCount } - | { kind: 'folder'; folder: WorkflowFolder } - const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, { id: 'documents', header: 'Documents', widthMultiplier: 0.6 }, @@ -111,8 +102,8 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' +const ROOT_BREADCRUMB_LABEL = 'Knowledge Base' const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const -const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { @@ -203,17 +194,11 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const { - currentFolderId, - setCurrentFolderId, - ancestors: breadcrumbs, - folders, - folderById, - foldersResolved, - } = useFolderNavigation({ - resourceType: FOLDER_RESOURCE_TYPE, - workspaceId, - }) + const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } = + useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -240,8 +225,6 @@ export function Knowledge() { const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) const { - sort: sortColumn, - dir: sortDirection, activeSort, onSort: onSortColumn, onClear: onClearSort, @@ -416,10 +399,28 @@ export function Knowledge() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) const needle = debouncedSearchQuery.trim().toLowerCase() - return needle + const searched = needle ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings - }, [folders, currentFolderId, debouncedSearchQuery]) + + const col = activeSort?.column ?? 'name' + const dir = activeSort?.direction ?? 'asc' + return [...searched].sort((a, b) => { + const aPinned = pinnedFolderIds.has(a.id) + const bPinned = pinnedFolderIds.has(b.id) + if (aPinned !== bPinned) return aPinned ? -1 : 1 + + let cmp = 0 + if (col === 'created') { + cmp = a.createdAt.getTime() - b.createdAt.getTime() + } else if (col === 'updated') { + cmp = a.updatedAt.getTime() - b.updatedAt.getTime() + } else { + cmp = a.name.localeCompare(b.name) + } + return dir === 'asc' ? cmp : -cmp + }) + }, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds]) const processedKBs = useMemo(() => { /** @@ -460,7 +461,45 @@ export function Knowledge() { result = result.filter((kb) => ownerFilter.includes(kb.userId)) } - return result + const col = activeSort?.column ?? 'updated' + const dir = activeSort?.direction ?? 'desc' + return [...result].sort((a, b) => { + // Pinned bases float to the top of every sort/direction — pinning is a + // user-declared priority, not another sort key to be inverted by `desc`. + const aPinned = pinnedBaseIds.has(a.id) + const bPinned = pinnedBaseIds.has(b.id) + if (aPinned !== bPinned) return aPinned ? -1 : 1 + + let cmp = 0 + switch (col) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'documents': + cmp = + ((a as KnowledgeBaseWithDocCount).docCount || 0) - + ((b as KnowledgeBaseWithDocCount).docCount || 0) + break + case 'tokens': + cmp = (a.tokenCount || 0) - (b.tokenCount || 0) + break + case 'created': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'updated': + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() + break + case 'connectors': + cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) + break + case 'owner': + cmp = (membersById.get(a.userId)?.name ?? '').localeCompare( + membersById.get(b.userId)?.name ?? '' + ) + break + } + return dir === 'asc' ? cmp : -cmp + }) }, [ knowledgeBases, currentFolderId, @@ -470,112 +509,52 @@ export function Knowledge() { connectorFilter, contentFilter, ownerFilter, - ]) - - /** - * Folders and bases sort as ONE list — a folder never outranks a base it ties with, so a - * pinned base reaches the top of the list rather than the top of the base section. - * - * Decorate-sort: each row's key + pinned flag is computed ONCE (O(N)) so the comparator - * never re-runs Date parsing or member lookups per comparison. Folders carry no document, - * token, or connector count, so those keys are `null` and land the folders last in both - * directions — matching the em-dash they show in those cells. - */ - const sortedEntries = useMemo(() => { - const entries: SortableResource[] = [] - - for (const folder of visibleFolders) { - entries.push({ - item: { kind: 'folder', folder }, - pinned: pinnedFolderIds.has(folder.id), - name: folder.name, - key: - sortColumn === 'documents' || sortColumn === 'tokens' || sortColumn === 'connectors' - ? null - : sortColumn === 'created' - ? new Date(folder.createdAt).getTime() - : sortColumn === 'updated' - ? new Date(folder.updatedAt).getTime() - : sortColumn === 'owner' - ? (membersById.get(folder.userId)?.name ?? null) - : folder.name, - }) - } - - for (const kb of processedKBs) { - entries.push({ - item: { kind: 'base', base: kb as KnowledgeBaseWithDocCount }, - pinned: pinnedBaseIds.has(kb.id), - name: kb.name, - key: - sortColumn === 'documents' - ? ((kb as KnowledgeBaseWithDocCount).docCount ?? 0) - : sortColumn === 'tokens' - ? (kb.tokenCount ?? 0) - : sortColumn === 'connectors' - ? (kb.connectorTypes?.length ?? 0) - : sortColumn === 'created' - ? new Date(kb.createdAt).getTime() - : sortColumn === 'updated' - ? new Date(kb.updatedAt).getTime() - : sortColumn === 'owner' - ? (membersById.get(kb.userId)?.name ?? null) - : kb.name, - }) - } - - return sortResources(entries, sortDirection) - }, [ - visibleFolders, - processedKBs, - sortColumn, - sortDirection, + activeSort, membersById, - pinnedFolderIds, pinnedBaseIds, ]) - const baseRows: ResourceRow[] = useMemo( - () => - sortedEntries.map(({ item, pinned }): ResourceRow => { - if (item.kind === 'folder') { - return folderRow(item.folder, { - pinned, - cells: { - documents: { label: EMPTY_CELL_PLACEHOLDER }, - tokens: { label: EMPTY_CELL_PLACEHOLDER }, - connectors: { label: EMPTY_CELL_PLACEHOLDER }, - created: timeCell(item.folder.createdAt), - owner: ownerCell(item.folder.userId, membersById), - updated: timeCell(item.folder.updatedAt), - }, - }) - } + const baseRows: ResourceRow[] = useMemo(() => { + const folderRows = visibleFolders.map((folder) => + folderRow(folder, { + pinned: pinnedFolderIds.has(folder.id), + cells: { + documents: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + connectors: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, membersById), + updated: timeCell(folder.updatedAt), + }, + }) + ) - const { base } = item - return { - id: base.id, - cells: { - name: { - icon: KNOWLEDGE_BASE_ICON, - label: base.name, - pinned, - }, - documents: { - label: String(base.docCount || 0), - }, - tokens: { - label: base.tokenCount ? base.tokenCount.toLocaleString() : '0', - }, - connectors: connectorCell(base.connectorTypes), - created: timeCell(base.createdAt), - owner: ownerCell(base.userId, membersById), - updated: timeCell(base.updatedAt), + const knowledgeBaseRows = processedKBs.map((kb) => { + const kbWithCount = kb as KnowledgeBaseWithDocCount + return { + id: kb.id, + cells: { + name: { + icon: KNOWLEDGE_BASE_ICON, + label: kb.name, + pinned: pinnedBaseIds.has(kb.id), }, - } - }), - [sortedEntries, membersById] - ) + documents: { + label: String(kbWithCount.docCount || 0), + }, + tokens: { + label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', + }, + connectors: connectorCell(kb.connectorTypes), + created: timeCell(kb.createdAt), + owner: ownerCell(kb.userId, membersById), + updated: timeCell(kb.updatedAt), + }, + } + }) + + return [...folderRows, ...knowledgeBaseRows] + }, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds]) /** * Rename is layered over the built rows rather than folded into the builder above, so a @@ -726,6 +705,11 @@ export function Knowledge() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId]) + useRegisterGlobalCommands(() => [ + { id: 'knowledge-new-base', handler: () => handleOpenCreateModal() }, + { id: 'knowledge-new-folder', handler: () => void handleCreateFolder() }, + ]) + const handleRenameFolder = useCallback(() => { const folder = activeFolderRef.current if (!folder) return @@ -913,7 +897,7 @@ export function Knowledge() { () => folderBreadcrumbItems({ rootLabel: ROOT_BREADCRUMB_LABEL, - rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon, + rootIcon: Database, breadcrumbs, onNavigate: setCurrentFolderId, currentFolderEditing: @@ -932,7 +916,6 @@ export function Knowledge() { ? [ { label: 'Rename', - icon: Pencil, onClick: () => { const folder = breadcrumbs[breadcrumbs.length - 1] breadcrumbRenameRef.current.startRename(folder.id, folder.name) @@ -940,7 +923,6 @@ export function Knowledge() { }, { label: 'Delete', - icon: Trash, onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]), }, ] @@ -975,8 +957,8 @@ export function Knowledge() { { id: 'tokens', label: 'Tokens' }, { id: 'connectors', label: 'Connectors' }, { id: 'created', label: 'Created' }, - { id: 'owner', label: 'Owner' }, { id: 'updated', label: 'Last Updated' }, + { id: 'owner', label: 'Owner' }, ], active: activeSort, onSort: onSortColumn, @@ -1028,6 +1010,7 @@ export function Knowledge() { onChange={(value) => setConnectorFilter(value === 'all' ? [] : [value])} align='start' fullWidth + flush />
@@ -1049,6 +1032,7 @@ export function Knowledge() { onChange={(value) => setContentFilter(value === 'all' ? [] : [value])} align='start' fullWidth + flush />
{memberOptions.length > 0 && ( @@ -1075,6 +1059,7 @@ export function Knowledge() { searchPlaceholder='Search members...' align='start' fullWidth + flush />
)} @@ -1113,7 +1098,7 @@ export function Knowledge() { <> [ + { id: 'logs-refresh', handler: () => handleRefresh() }, + { id: 'logs-export', handler: () => void handleExport() }, + { id: 'logs-show-dashboard', handler: () => setViewMode('dashboard') }, + { id: 'logs-show-logs', handler: () => setViewMode('logs') }, + ]) + const loadMoreLogs = useCallback(() => { const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current if (!isFetching && hasNextPage) { diff --git a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx index ac154f11241..c234881e0a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/global-commands-provider.tsx @@ -27,14 +27,15 @@ export interface ParsedShortcut { export interface GlobalCommand { id?: string - shortcut: string + /** Keyboard binding. Omit for palette-only commands invoked by id. */ + shortcut?: string allowInEditable?: boolean handler: (event: KeyboardEvent) => void } interface RegistryCommand extends GlobalCommand { id: string - parsed: ParsedShortcut + parsed: ParsedShortcut | null } interface GlobalCommandsContextValue { @@ -130,7 +131,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { const createdIds: string[] = [] for (const cmd of commands) { const id = cmd.id ?? generateId() - const parsed = parseShortcut(cmd.shortcut) + const parsed = cmd.shortcut ? parseShortcut(cmd.shortcut) : null registryRef.current.set(id, { ...cmd, id, @@ -152,6 +153,7 @@ export function GlobalCommandsProvider({ children }: { children: ReactNode }) { if (e.isComposing) return for (const [, cmd] of registryRef.current) { + if (!cmd.parsed) continue if (!cmd.allowInEditable && isEditableElement(document.activeElement)) continue if (matchesShortcut(e, cmd.parsed)) { diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx new file mode 100644 index 00000000000..f1b5eaac653 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx @@ -0,0 +1,236 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { Calendar, Plus } from '@sim/emcn/icons' +import { useParams } from 'next/navigation' +import type { ResourceAction } from '@/app/workspace/[workspaceId]/components' +import { Resource } from '@/app/workspace/[workspaceId]/components' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { ScheduleCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-calendar' +import { ScheduleListContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/schedule-list-context-menu' +import { TaskContextMenu } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-context-menu' +import { TaskDeleteDialog } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-delete-dialog' +import { TaskDetailsModal } from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-details-modal' +import { + TaskModal, + type TaskPrefill, +} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal' +import { useCalendar } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar' +import { useScheduledTasks } from '@/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-scheduled-tasks' +import { visibleRange } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/calendar-grid' +import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useTimezone } from '@/hooks/queries/general-settings' + +export function ScheduledTasks() { + const { workspaceId } = useParams<{ workspaceId: string }>() + const timezone = useTimezone() + const calendar = useCalendar(timezone) + + const range = useMemo( + () => visibleRange(calendar.scope, calendar.anchor), + [calendar.scope, calendar.anchor] + ) + const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) + + /** Pending tasks open the editable TaskModal; running/finished open the record. */ + const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null + const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null + const editSeed = editTask ? tasks.editSeedFor(editTask) : null + + const { + isOpen: isListContextMenuOpen, + position: listContextMenuPosition, + handleContextMenu: handleListContextMenu, + closeMenu: closeListContextMenu, + } = useContextMenu() + + const { + isOpen: isTaskContextMenuOpen, + position: taskContextMenuPosition, + handleContextMenu: handleTaskCtxMenu, + closeMenu: closeTaskContextMenu, + } = useContextMenu() + + /** The right-clicked task — drives the context menu items. */ + const [contextTask, setContextTask] = useState(null) + /** The task targeted for deletion — drives the (recurring-aware) delete dialog. */ + const [deletingTask, setDeletingTask] = useState(null) + /** Pre-fill for a duplicate — opens the create modal seeded from an existing task. */ + const [duplicatePrefill, setDuplicatePrefill] = useState(null) + + /** Starts a blank create. The three modal sources are mutually exclusive, so it closes the others. */ + const handleOpenCreate = useCallback(() => { + setDuplicatePrefill(null) + tasks.closeTask() + calendar.openCreate() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [calendar.openCreate]) + + useRegisterGlobalCommands(() => [ + { id: 'scheduled-tasks-new', handler: () => handleOpenCreate() }, + ]) + + /** Starts a slot-seeded create, closing any other open modal. */ + const handleSelectSlot = useCallback( + (date: Date, time?: string) => { + setDuplicatePrefill(null) + tasks.closeTask() + calendar.selectSlot(date, time) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [calendar.selectSlot] + ) + + /** Opens a task's edit/record modal, closing any create/duplicate flow. */ + const handleOpenTask = useCallback( + (task: ScheduledTask) => { + setDuplicatePrefill(null) + calendar.closeCreate() + tasks.openTask(task) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [calendar.closeCreate] + ) + + const handleDuplicate = useCallback(() => { + if (!contextTask) return + const seed = tasks.editSeedFor(contextTask) + if (!seed) return + const { scheduleId: _scheduleId, ...prefill } = seed + calendar.closeCreate() + tasks.closeTask() + setDuplicatePrefill(prefill) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextTask, calendar.closeCreate]) + + const handleTaskContextMenu = useCallback( + (task: ScheduledTask, e: React.MouseEvent) => { + closeListContextMenu() + setContextTask(task) + handleTaskCtxMenu(e) + }, + [closeListContextMenu, handleTaskCtxMenu] + ) + + /** Opens the right-clicked task's modal (edit for pending, record otherwise). */ + const openContextTask = useCallback(() => { + if (contextTask) handleOpenTask(contextTask) + }, [contextTask, handleOpenTask]) + + const handlePauseContextTask = useCallback(() => { + if (contextTask) tasks.pauseTask(contextTask.scheduleId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextTask]) + + const handleResumeContextTask = useCallback(() => { + if (contextTask) tasks.resumeTask(contextTask.scheduleId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contextTask]) + + const handleContentContextMenu = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement + if ( + target.closest('[data-resource-row]') || + target.closest('button, input, a, [role="button"]') + ) { + return + } + handleListContextMenu(e) + }, + [handleListContextMenu] + ) + + const headerActions: ResourceAction[] = useMemo( + () => [ + { + text: 'New scheduled task', + icon: Plus, + onSelect: handleOpenCreate, + variant: 'primary', + }, + ], + [handleOpenCreate] + ) + + return ( + <> + + + + + + + + setDeletingTask(contextTask)} + /> + + setDeletingTask(null)} + onDeleteOccurrence={(task) => tasks.deleteOccurrence(task.scheduleId, task.runAt)} + onDeleteSeries={(task) => tasks.deleteTask(task.scheduleId)} + /> + + { + if (!open) { + calendar.closeCreate() + setDuplicatePrefill(null) + } + }} + slot={duplicatePrefill ? null : calendar.selectedSlot} + prefill={duplicatePrefill} + onSubmit={tasks.createTask} + /> + + { + if (!open) tasks.closeTask() + }} + edit={editSeed} + onSubmit={(draft) => { + if (editTask) return tasks.updateTask(editTask.scheduleId, draft) + }} + onRequestDelete={() => { + setDeletingTask(editTask) + tasks.closeTask() + }} + /> + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 026bc88cf55..d1f0015b6bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -36,6 +36,7 @@ import { } from '@/app/workspace/[workspaceId]/components/folders' import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars' import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu' @@ -1068,6 +1069,12 @@ export function Table({ } }, [tableData, workspaceId]) + useRegisterGlobalCommands(() => [ + { id: 'table-new-column', handler: () => handleAddColumnOfType('string') }, + { id: 'table-export-csv', handler: () => void handleExportCsv() }, + { id: 'table-import-csv', handler: () => setIsImportCsvOpen(true) }, + ]) + const columnOptions = useMemo( () => columns.map((col) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 7a95bbd3ae8..01be8192160 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -46,6 +46,7 @@ import { useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ImportCsvDialog, @@ -1033,6 +1034,12 @@ export function Tables() { } }, [workspaceId, folders, currentFolderId, createFolderAsync, setSearchTerm, startFolderRename]) + useRegisterGlobalCommands(() => [ + { id: 'tables-new-table', handler: () => void handleCreateTable() }, + { id: 'tables-new-folder', handler: () => void handleCreateFolder() }, + { id: 'tables-import-csv', handler: () => csvInputRef.current?.click() }, + ]) + const headerActions: ResourceAction[] = useMemo( () => [ { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 42c2169efb8..42a0a7c5891 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Chip, Tooltip } from '@sim/emcn' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -75,6 +76,8 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: } } + useRegisterGlobalCommands(() => [{ id: 'deploy-workflow', handler: () => void onDeployClick() }]) + const getTooltipText = () => { if (isEmpty) { return 'Cannot deploy an empty workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx index 4118fc26858..fdfa31619c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx @@ -46,21 +46,7 @@ describe('CommandFadedList', () => { vi.unstubAllGlobals() }) - it('insets the palette scrollbar track below the search field', () => { - act(() => { - root.render( - - - - ) - }) - - const list = container.querySelector('[cmdk-list]') - expect(list?.className).toContain('[&::-webkit-scrollbar-track]:mt-12') - expect(list?.className).toContain('[&::-webkit-scrollbar-track]:mb-1.5') - }) - - it('uses the canvas search surface and content fade in the palette', () => { + it('fades the palette with the short single mask and the shared search surface', () => { act(() => { root.render( @@ -72,7 +58,8 @@ describe('CommandFadedList', () => { const list = container.querySelector('[cmdk-list]') const search = container.querySelector('[cmdk-input]')?.parentElement - expect(list?.className).toContain('transparent_8%,black_18%,black_94%') + expect(list?.className).toContain('transparent_8%,black_13%,black_97%') + expect(list?.className).not.toContain('scrollbar-track') expect(search?.className).toContain('var(--surface-2)') }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 0b5134d50e2..6f99f2b52e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -24,11 +24,17 @@ const SEARCH_SURFACE_CLASSNAME = { 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', } as const +/** + * The palette hides its scrollbar (`scrollbar-none` at the call site), so it + * fades with one plain mask; its band is kept short — fully masked only under + * the floating input (0–8%), legible by 13%, and a brief 97–100% exit — so + * rows spend less time in the fog than on the canvas surface. + */ const LIST_FADE_CLASSNAME = { canvas: '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%)]', palette: - '[&::-webkit-scrollbar-track]:mt-12 [&::-webkit-scrollbar-track]:mb-1.5 [-webkit-mask-composite:source-over] [-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%),linear-gradient(black,black)] [-webkit-mask-position:left_top,right_top] [-webkit-mask-repeat:no-repeat,no-repeat] [-webkit-mask-size:calc(100%_-_8px)_100%,8px_100%] [mask-composite:add] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_18%,black_94%,transparent_100%),linear-gradient(black,black)] [mask-position:left_top,right_top] [mask-repeat:no-repeat,no-repeat] [mask-size:calc(100%_-_8px)_100%,8px_100%]', + '[-webkit-mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)] [mask-image:linear-gradient(to_bottom,transparent_0%,transparent_8%,black_13%,black_97%,transparent_100%)]', } as const /** Borderless search field layered over a fading command-result list. */ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index b0738ad6dab..a48329b5d64 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -24,6 +24,38 @@ function ItemMeta({ meta }: ItemMetaProps) { ) } +interface ItemFolderPathProps { + folderPath: string[] + className?: string +} + +/** Trailing folder-path receipt whose head segments yield space to the leaf. */ +function ItemFolderPath({ folderPath, className }: ItemFolderPathProps) { + return ( + + {folderPath.length > 1 && ( + <> + + {folderPath.slice(0, -1).join(' / ')} + + / + + )} + {folderPath[folderPath.length - 1]} + + ) +} + +/** Structural equality for the optional folder-path prop in memo comparators. */ +function sameFolderPath(prev?: string[], next?: string[]): boolean { + return ( + prev === next || + (prev?.length === next?.length && (prev ?? []).every((segment, i) => segment === next?.[i])) + ) +} + interface ShortcutHintProps { shortcut: string } @@ -162,17 +194,7 @@ export const MemoizedWorkflowItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - + ) : null} ) @@ -182,9 +204,7 @@ export const MemoizedWorkflowItem = memo( prev.name === next.name && prev.isCurrent === next.isCurrent && prev.meta === next.meta && - (prev.folderPath === next.folderPath || - (prev.folderPath?.length === next.folderPath?.length && - (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) + sameFolderPath(prev.folderPath, next.folderPath) ) export const MemoizedFileItem = memo( @@ -211,17 +231,7 @@ export const MemoizedFileItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - - {folderPath.length > 1 && ( - <> - - {folderPath.slice(0, -1).join(' / ')} - - / - - )} - {folderPath[folderPath.length - 1]} - + ) : null} ) @@ -230,9 +240,7 @@ export const MemoizedFileItem = memo( prev.value === next.value && prev.name === next.name && prev.meta === next.meta && - (prev.folderPath === next.folderPath || - (prev.folderPath?.length === next.folderPath?.length && - (prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i]))) + sameFolderPath(prev.folderPath, next.folderPath) ) export const MemoizedTaskItem = memo( @@ -351,18 +359,26 @@ export const MemoizedIconItem = memo( onSelect, name, icon: Icon, + folderPath, meta, }: { value: string onSelect: () => void name: string icon: ComponentType<{ className?: string }> + folderPath?: string[] } & ResultMetaProps) { return ( - {name} - {meta && } + + {name} + + {meta ? ( + + ) : folderPath && folderPath.length > 0 ? ( + + ) : null} ) }, @@ -370,5 +386,6 @@ export const MemoizedIconItem = memo( prev.value === next.value && prev.name === next.name && prev.icon === next.icon && - prev.meta === next.meta + prev.meta === next.meta && + sameFolderPath(prev.folderPath, next.folderPath) ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts index 8961455f1bd..f865dbe8b77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/index.ts @@ -8,21 +8,4 @@ export { MemoizedWorkflowItem, MemoizedWorkspaceItem, } from './command-items' -export { - ActionsGroup, - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - SearchEntryGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './search-groups' +export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts index 18b43c2ae4f..e9e7a074e98 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/index.ts @@ -1,18 +1 @@ -export { - ActionsGroup, - BlocksGroup, - ChatsGroup, - ConnectedAccountsGroup, - DocsGroup, - FilesGroup, - IntegrationsGroup, - KnowledgeBasesGroup, - PagesGroup, - SearchEntryGroup, - TablesGroup, - ToolOpsGroup, - ToolsGroup, - TriggersGroup, - WorkflowsGroup, - WorkspacesGroup, -} from './search-groups' +export { BlocksGroup, SearchEntryGroup, ToolsGroup } from './search-groups' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx index d92110a9bbb..75a8e76b7ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.test.tsx @@ -5,10 +5,7 @@ import { act } from 'react' import { Command } from 'cmdk' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - SearchEntryGroup, - WorkspacesGroup, -} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups' +import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups' import type { SearchEntry, SearchEntryHandlers, @@ -23,17 +20,13 @@ const handlers: SearchEntryHandlers = { onSelectAction: vi.fn(), onSelectConnectedAccount: vi.fn(), onSelectIntegration: vi.fn(), - onSelectBlock: vi.fn(), - onSelectTool: vi.fn(), - onSelectTrigger: vi.fn(), onSelectChat: vi.fn(), onSelectWorkflow: vi.fn(), onSelectTable: vi.fn(), onSelectFile: vi.fn(), onSelectKnowledgeBase: vi.fn(), - onSelectToolOperation: vi.fn(), + onSelectLog: vi.fn(), onSelectWorkspace: vi.fn(), - onSelectDoc: vi.fn(), onSelectPage: vi.fn(), } @@ -118,7 +111,6 @@ describe('SearchEntryGroup', () => { }) expect(container.textContent).toContain('Run workflow') - expect(container.textContent).toContain('Workflow') expect(container.querySelector('[cmdk-group-heading]')).toBeNull() expect(container.querySelector('button[aria-label*="favorites"]')).toBeNull() }) @@ -147,7 +139,12 @@ describe('SearchEntryGroup', () => { root.render( - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index d505f0c94e2..d45a7fa8847 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -1,7 +1,8 @@ 'use client' -import type { ComponentType, ReactElement } from 'react' +import type { ReactElement } from 'react' import { memo } from 'react' +import { Library } from '@sim/emcn' import { Database, Table } from '@sim/emcn/icons' import { Command } from 'cmdk' import { @@ -15,53 +16,13 @@ import { MemoizedWorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' import type { - ActionItem, - FileItem, - FolderedItem, - IntegrationSearchItem, - PageItem, SearchEntry, SearchEntryHandlers, - TaskItem, - WorkflowItem, - WorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -import { - GROUP_HEADING_CLASSNAME, - getActionGroupLabel, - getToolOperationLabel, - SECTION_LABELS, -} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -import type { - SearchBlockItem, - SearchDocItem, - SearchToolOperationItem, -} from '@/stores/modals/search/types' - -export const ActionsGroup = memo(function ActionsGroup({ - items, - onSelect, -}: { - items: ActionItem[] - onSelect: (action: ActionItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((action) => ( - onSelect(action)} - icon={action.icon} - name={action.name} - shortcut={action.shortcut} - /> - ))} - - ) -}) +import { GROUP_HEADING_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +import type { SearchBlockItem } from '@/stores/modals/search/types' +/** Canvas block group, consumed by the connection block selector. */ export const BlocksGroup = memo(function BlocksGroup({ items, onSelect, @@ -90,6 +51,7 @@ export const BlocksGroup = memo(function BlocksGroup({ ) }) +/** Canvas tool group, consumed by the connection block selector. */ export const ToolsGroup = memo(function ToolsGroup({ items, onSelect, @@ -115,272 +77,8 @@ export const ToolsGroup = memo(function ToolsGroup({ ) }) -export const TriggersGroup = memo(function TriggersGroup({ - items, - onSelect, -}: { - items: SearchBlockItem[] - onSelect: (trigger: SearchBlockItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((trigger) => ( - onSelect(trigger)} - icon={trigger.icon} - bgColor={trigger.bgColor} - showColoredIcon - label={trigger.name} - /> - ))} - - ) -}) - -export const ToolOpsGroup = memo(function ToolOpsGroup({ - items, - onSelect, -}: { - items: SearchToolOperationItem[] - onSelect: (op: SearchToolOperationItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((op) => ( - onSelect(op)} - icon={op.icon} - bgColor={op.bgColor} - showColoredIcon - label={op.name} - /> - ))} - - ) -}) - -export const DocsGroup = memo(function DocsGroup({ - items, - onSelect, -}: { - items: SearchDocItem[] - onSelect: (doc: SearchDocItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((doc) => ( - onSelect(doc)} - icon={doc.icon} - bgColor='#6B7280' - showColoredIcon - label={doc.name} - /> - ))} - - ) -}) - -export const WorkflowsGroup = memo(function WorkflowsGroup({ - items, - onSelect, -}: { - items: WorkflowItem[] - onSelect: (workflow: WorkflowItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((workflow) => ( - onSelect(workflow)} - name={workflow.name} - folderPath={workflow.folderPath} - isCurrent={workflow.isCurrent} - /> - ))} - - ) -}) - -export const ChatsGroup = memo(function ChatsGroup({ - items, - onSelect, -}: { - items: TaskItem[] - onSelect: (task: TaskItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((task) => ( - onSelect(task)} - name={task.name} - /> - ))} - - ) -}) - -export const WorkspacesGroup = memo(function WorkspacesGroup({ - items, - onSelect, -}: { - items: WorkspaceItem[] - onSelect: (workspace: WorkspaceItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((workspace) => ( - onSelect(workspace)} - name={workspace.name} - isCurrent={workspace.isCurrent} - logoUrl={workspace.logoUrl} - color={workspace.color} - /> - ))} - - ) -}) - -export const PagesGroup = memo(function PagesGroup({ - items, - onSelect, -}: { - items: PageItem[] - onSelect: (page: PageItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((page) => ( - onSelect(page)} - icon={page.icon} - name={page.name} - shortcut={page.shortcut} - /> - ))} - - ) -}) - -export const TablesGroup = createIconGroup('Tables', 'table', Table) -export const KnowledgeBasesGroup = createIconGroup('Knowledge bases', 'knowledge-base', Database) - -export const ConnectedAccountsGroup = createColoredIconGroup('Connected', 'connected-account') -export const IntegrationsGroup = createColoredIconGroup('Integrations', 'integration') - -export const FilesGroup = memo(function FilesGroup({ - items, - onSelect, -}: { - items: FileItem[] - onSelect: (file: FileItem) => void -}) { - if (items.length === 0) return null - return ( - - {items.map((file) => ( - onSelect(file)} - name={file.name} - folderPath={file.folderPath} - /> - ))} - - ) -}) - -/** - * Factory for groups that render each item with its own brand icon on a - * brand-colored tile (the same `showColoredIcon` pattern used by - * `BlocksGroup` / `ToolsGroup`). Used for integrations and connected accounts - * where every row has a distinct per-item icon and brand color. - */ -function createColoredIconGroup(heading: string, prefix: string) { - return memo(function ColoredIconGroup({ - items, - onSelect, - }: { - items: IntegrationSearchItem[] - onSelect: (item: IntegrationSearchItem) => void - }) { - if (items.length === 0) return null - return ( - - {items.map((item) => ( - onSelect(item)} - icon={item.icon} - bgColor={item.bgColor} - showColoredIcon - label={item.name} - /> - ))} - - ) - }) -} - -function createIconGroup( - heading: string, - prefix: string, - icon: ComponentType<{ className?: string }> -) { - return memo(function IconGroup({ - items, - onSelect, - }: { - items: FolderedItem[] - onSelect: (item: FolderedItem) => void - }) { - if (items.length === 0) return null - return ( - - {items.map((item) => ( - onSelect(item)} - name={item.name} - icon={icon} - folderPath={item.folderPath} - /> - ))} - - ) - }) -} - interface RenderEntryOptions { keyPrefix: string - meta?: string - search: string } function renderSearchEntry( @@ -389,9 +87,6 @@ function renderSearchEntry( options: RenderEntryOptions ): ReactElement { const key = `${options.keyPrefix}${entry.section}-${entry.item.id}` - const rowProps = { - meta: options.meta, - } switch (entry.section) { case 'actions': @@ -403,7 +98,6 @@ function renderSearchEntry( icon={entry.item.icon} name={entry.item.name} shortcut={entry.item.shortcut} - {...rowProps} /> ) case 'connectedAccounts': @@ -416,7 +110,6 @@ function renderSearchEntry( bgColor={entry.item.bgColor} showColoredIcon label={entry.item.name} - {...rowProps} /> ) case 'integrations': @@ -429,47 +122,6 @@ function renderSearchEntry( bgColor={entry.item.bgColor} showColoredIcon label={entry.item.name} - {...rowProps} - /> - ) - case 'blocks': - return ( - handlers.onSelectBlock(entry.item)} - icon={entry.item.icon} - bgColor={entry.item.bgColor} - showColoredIcon - workflowType={entry.item.type} - label={entry.item.name} - {...rowProps} - /> - ) - case 'tools': - return ( - handlers.onSelectTool(entry.item)} - icon={entry.item.icon} - bgColor={entry.item.bgColor} - showColoredIcon - label={entry.item.name} - {...rowProps} - /> - ) - case 'triggers': - return ( - handlers.onSelectTrigger(entry.item)} - icon={entry.item.icon} - bgColor={entry.item.bgColor} - showColoredIcon - label={entry.item.name} - {...rowProps} /> ) case 'chats': @@ -479,7 +131,7 @@ function renderSearchEntry( value={`${entry.item.name} ${key}`} onSelect={() => handlers.onSelectChat(entry.item)} name={entry.item.name} - {...rowProps} + meta={entry.item.date} /> ) case 'workflows': @@ -491,18 +143,17 @@ function renderSearchEntry( name={entry.item.name} folderPath={entry.item.folderPath} isCurrent={entry.item.isCurrent} - {...rowProps} /> ) case 'tables': return ( handlers.onSelectTable(entry.item)} name={entry.item.name} icon={Table} - {...rowProps} + folderPath={entry.item.folderPath} /> ) case 'files': @@ -513,31 +164,28 @@ function renderSearchEntry( onSelect={() => handlers.onSelectFile(entry.item)} name={entry.item.name} folderPath={entry.item.folderPath} - {...rowProps} /> ) case 'knowledgeBases': return ( handlers.onSelectKnowledgeBase(entry.item)} name={entry.item.name} icon={Database} - {...rowProps} + folderPath={entry.item.folderPath} /> ) - case 'toolOperations': + case 'logs': return ( - handlers.onSelectToolOperation(entry.item)} - icon={entry.item.icon} - bgColor={entry.item.bgColor} - showColoredIcon - label={getToolOperationLabel(entry.item, options.search)} - {...rowProps} + value={`${entry.item.name} ${key}`} + onSelect={() => handlers.onSelectLog(entry.item)} + name={entry.item.name} + icon={Library} + meta={entry.item.date} /> ) case 'workspaces': @@ -550,20 +198,6 @@ function renderSearchEntry( isCurrent={entry.item.isCurrent} logoUrl={entry.item.logoUrl} color={entry.item.color} - {...rowProps} - /> - ) - case 'docs': - return ( - handlers.onSelectDoc(entry.item)} - icon={entry.item.icon} - bgColor='#6B7280' - showColoredIcon - label={entry.item.name} - {...rowProps} /> ) case 'pages': @@ -575,7 +209,6 @@ function renderSearchEntry( icon={entry.item.icon} name={entry.item.name} shortcut={entry.item.shortcut} - {...rowProps} /> ) } @@ -584,7 +217,6 @@ function renderSearchEntry( interface SearchEntryGroupProps { variant: 'section' | 'results' heading?: string - search?: string entries: SearchEntry[] handlers: SearchEntryHandlers } @@ -593,25 +225,13 @@ interface SearchEntryGroupProps { export const SearchEntryGroup = memo(function SearchEntryGroup({ variant, heading, - search = '', entries, handlers, }: SearchEntryGroupProps) { if (entries.length === 0) return null const keyPrefix = variant === 'results' ? 'results-' : '' - const renderedEntries = entries.map((entry) => - renderSearchEntry(entry, handlers, { - keyPrefix, - meta: - variant === 'results' - ? entry.section === 'actions' - ? getActionGroupLabel(entry.item) - : SECTION_LABELS[entry.section] - : undefined, - search, - }) - ) + const renderedEntries = entries.map((entry) => renderSearchEntry(entry, handlers, { keyPrefix })) if (variant === 'results') { return {renderedEntries} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx new file mode 100644 index 00000000000..63be8a4ad30 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -0,0 +1,305 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { + MOTHERSHIP_SEND_MESSAGE_EVENT, + type MothershipSendMessageDetail, +} from '@/lib/mothership/events' +import { SearchModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal' + +const { mockPush } = vi.hoisted(() => ({ + mockPush: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useParams: () => ({ workspaceId: 'workspace-1', workflowId: 'workflow-1' }), + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('posthog-js/react', () => ({ + usePostHog: () => ({}), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isChatEnabled: true, +})) + +vi.mock('@/lib/posthog/client', () => ({ + captureEvent: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ + useInvokeGlobalCommand: () => vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({ + SIDEBAR_SCROLL_EVENT: 'sidebar-scroll-to-item', +})) + +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + config: { + hideIntegrationsTab: false, + hideTablesTab: false, + hideFilesTab: false, + hideKnowledgeBaseTab: false, + }, + }), +})) + +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }), +})) + +async function enterSearchQuery(query: string): Promise { + const input = document.querySelector('input[aria-label="Search anything"]') + if (!input) throw new Error('Search input not found') + + await act(async () => { + const valueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + 'value' + )?.set + valueSetter?.call(input, query) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) +} + +describe('SearchModal', () => { + let container: HTMLDivElement + let root: Root + let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + localStorage.clear() + mockPush.mockClear() + window.history.replaceState({}, '', '/workspace/workspace-1/w/workflow-1') + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + originalScrollIntoView = HTMLElement.prototype.scrollIntoView + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.querySelectorAll('[role="dialog"]').forEach((dialog) => dialog.remove()) + if (originalScrollIntoView) { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView') + } + vi.unstubAllGlobals() + }) + + it('offers a new chat with the query when search has no results', async () => { + const onOpenChange = vi.fn() + await act(async () => { + root.render() + }) + + await enterSearchQuery('explain quantum rainbows') + + const result = document.querySelector('[cmdk-item]') + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1) + expect(result?.textContent).toBe('New Chat: explain quantum rainbows') + expect(result?.querySelector('svg')).not.toBeNull() + expect(result?.getAttribute('aria-selected')).toBe('true') + + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home?handoff=1') + expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ + message: 'explain quantum rainbows', + contexts: undefined, + }) + }) + + it('sends the query directly when the new-chat surface is already mounted', async () => { + window.history.replaceState({}, '', '/workspace/workspace-1/home') + const receivedMessages: string[] = [] + const handleMessage = (event: Event) => { + receivedMessages.push((event as CustomEvent).detail.message) + event.preventDefault() + } + window.addEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) + + try { + await act(async () => { + root.render() + }) + await enterSearchQuery('summarize this workspace') + + act(() => { + document.querySelector('[cmdk-item]')?.click() + }) + + expect(receivedMessages).toEqual(['summarize this workspace']) + expect(mockPush).not.toHaveBeenCalled() + expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() + } finally { + window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) + } + }) + + it('returns selection to matching results after showing the new-chat fallback', async () => { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('nothing matches this') + expect(document.querySelector('[cmdk-item]')?.getAttribute('aria-selected')).toBe('true') + + await enterSearchQuery('rainbow') + const result = document.querySelector('[cmdk-item]') + expect(result?.textContent).toContain('Rainbow workflow') + expect(result?.getAttribute('aria-selected')).toBe('true') + }) + + it('orders canvas browse groups as Workflow Actions, Platform Actions, then the standard tail', async () => { + const workflows = [ + { id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' }, + ] + await act(async () => { + root.render( + + ) + }) + + const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( + (el) => el.textContent + ) + expect(headings.slice(0, 4)).toEqual(['Workflow Actions', 'Platform', 'Pages', 'Workflows']) + }) + + it('hoists a module page’s actions and entity section above Platform Actions', async () => { + const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }] + await act(async () => { + root.render( + + ) + }) + + const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( + (el) => el.textContent + ) + expect(headings.slice(0, 4)).toEqual(['Table Actions', 'Tables', 'Platform', 'Pages']) + }) + + it('browses the integrations catalog from every page', async () => { + const Icon = () => null + const integrations = [ + { id: 'slack', name: 'Slack', href: '/integrations/slack', icon: Icon, bgColor: '#611f69' }, + ] + await act(async () => { + root.render() + }) + + const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( + (el) => el.textContent + ) + expect(headings).toContain('Integrations') + + await enterSearchQuery('slack') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent + ) + expect(rows.some((text) => text?.includes('Slack'))).toBe(true) + }) + + it('re-anchors selection to the first row on every open', async () => { + const workflows = [ + { id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' }, + { id: 'workflow-b', name: 'Beta workflow', href: '/workspace/workspace-1/w/workflow-b' }, + ] + await act(async () => { + root.render() + }) + + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }) + ) + }) + const rows = () => Array.from(document.querySelectorAll('[cmdk-item]')) + expect(rows()[1]?.getAttribute('aria-selected')).toBe('true') + + await act(async () => { + root.render() + }) + await act(async () => { + root.render() + }) + + expect(rows()[0]?.getAttribute('aria-selected')).toBe('true') + expect(rows()[1]?.getAttribute('aria-selected')).toBe('false') + }) + + it('keeps the palette open when the query handoff cannot be persisted', async () => { + const onOpenChange = vi.fn() + const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) + + try { + await act(async () => { + root.render() + }) + await enterSearchQuery('draft a launch plan') + + act(() => { + document.querySelector('[cmdk-item]')?.click() + }) + + expect(onOpenChange).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + } finally { + storeSpy.mockRestore() + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index ad1c00aee3e..16a2b88f894 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -4,7 +4,9 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } f import { cn, Library } from '@sim/emcn' import { Calendar, + Columns3, Database, + Download, Duplicate, File, FolderPlus, @@ -12,11 +14,16 @@ import { Home, Integration, Key, + Pencil, Play, Plus, + RefreshCw, + Rocket, Send, Settings, Table, + TagIcon, + Trash, Upload, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -26,23 +33,29 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { isChatEnabled } from '@/lib/core/config/env-flags' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' -import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { toSearchToken } from '@/lib/search/tokens' +import { getMothershipHandoffHref } from '@/app/workspace/[workspaceId]/home/search-params' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { CommandFadedList, CommandSearch, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome' +import { MemoizedActionItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items' import { SearchEntryGroup } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups' import type { ActionGroupLabel, ActionItem, FileItem, IntegrationSearchItem, + LogItem, PageItem, SearchEntry, SearchEntryHandlers, SearchModalProps, + SearchSection, TaskItem, WorkflowItem, WorkspaceItem, @@ -50,7 +63,10 @@ import type { import { getActionGroupLabel, getGlobalSearchResults, + getPageActionGroupLabel, MAX_RESULTS_PER_GROUP, + PAGE_CONTEXT_HOISTED_SECTION, + SEARCH_SECTIONS, SECTION_LABELS, scoreActions, scoreSectionItems, @@ -62,14 +78,6 @@ import { import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' -import { useSearchModalStore } from '@/stores/modals/search/store' -import type { - SearchBlockItem, - SearchDocItem, - SearchSection, - SearchToolOperationItem, -} from '@/stores/modals/search/types' -import { SEARCH_SECTIONS } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') @@ -84,10 +92,11 @@ export function SearchModal({ tables = [], files = [], knowledgeBases = [], + logs = [], integrations = [], connectedAccounts = [], isOnWorkflowPage = false, - isOnIntegrationsPage = false, + pageContext = null, canEdit = false, onCreateWorkflow, onCreateFolder, @@ -96,8 +105,8 @@ export function SearchModal({ const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string - const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) + const listRef = useRef(null) const [mounted, setMounted] = useState(false) const { navigateToSettings } = useSettingsNavigation() const { config: permissionConfig } = usePermissionConfig() @@ -115,16 +124,6 @@ export function SearchModal({ setMounted(true) }, []) - const { blocks, tools, triggers, toolOperations, docs } = useSearchModalStore( - (state) => state.data - ) - - const sections = useSearchModalStore((state) => state.sections) - const displaySections = useMemo( - () => SEARCH_SECTIONS.filter((section) => !sections || sections.includes(section)), - [sections] - ) - const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) }, []) @@ -210,15 +209,49 @@ export function SearchModal({ */ const actions = useMemo((): ActionItem[] => { const list: ActionItem[] = [] - list.push({ - id: 'run-workflow', - name: 'Run workflow', - keywords: 'execute start play test', - icon: Play, - shortcut: '⌘↵', - context: 'workflow', - run: () => invokeCommand('run-workflow'), - }) + const onCanvas = pageContext === 'workflow' + const invoke = (id: string) => () => invokeCommand(id) + + list.push( + { + id: 'run-workflow', + name: 'Run workflow', + keywords: 'execute start play test', + icon: Play, + shortcut: '⌘↵', + context: 'workflow', + run: invoke('run-workflow'), + }, + { + id: 'deploy-workflow', + name: 'Deploy workflow', + keywords: 'ship release publish api', + icon: Rocket, + context: 'workflow', + run: invoke('deploy-workflow'), + }, + { + id: 'fit-to-view', + name: 'Fit workflow to view', + keywords: 'zoom center recenter canvas reset', + icon: Scan, + shortcut: '⇧⌘F', + context: 'workflow', + run: invoke('fit-to-view'), + }, + { + id: 'copy-workflow-url', + name: 'Copy workflow link', + keywords: 'url share clipboard', + icon: Duplicate, + context: 'workflow', + run: () => { + navigator.clipboard.writeText(window.location.href).catch((error) => { + logger.error('Failed to copy workflow link to clipboard', { error }) + }) + }, + } + ) if (isChatEnabled) { list.push({ id: 'new-chat', @@ -229,13 +262,15 @@ export function SearchModal({ run: () => routerRef.current.push(`/workspace/${workspaceId}/home`), }) } + /* On the canvas these three join the Workflow Actions group; everywhere + else they are platform verbs. */ if (canEdit && onCreateWorkflow) { list.push({ id: 'create-workflow', name: 'Create workflow', keywords: 'new add build', icon: Plus, - context: 'global', + context: onCanvas ? 'workflow' : 'global', run: onCreateWorkflow, }) } @@ -245,7 +280,7 @@ export function SearchModal({ name: 'Create folder', keywords: 'new add group', icon: FolderPlus, - context: 'global', + context: onCanvas ? 'workflow' : 'global', run: onCreateFolder, }) } @@ -255,31 +290,10 @@ export function SearchModal({ name: 'Import workflow', keywords: 'upload add', icon: Upload, - context: 'global', + context: onCanvas ? 'workflow' : 'global', run: onImportWorkflow, }) } - list.push({ - id: 'fit-to-view', - name: 'Fit workflow to view', - keywords: 'zoom center recenter canvas reset', - icon: Scan, - shortcut: '⇧⌘F', - context: 'workflow', - run: () => invokeCommand('fit-to-view'), - }) - list.push({ - id: 'copy-workflow-url', - name: 'Copy workflow link', - keywords: 'url share clipboard', - icon: Duplicate, - context: 'workflow', - run: () => { - navigator.clipboard.writeText(window.location.href).catch((error) => { - logger.error('Failed to copy workflow link to clipboard', { error }) - }) - }, - }) list.push({ id: 'invite-teammates', name: 'Invite teammates', @@ -288,10 +302,247 @@ export function SearchModal({ context: 'global', run: () => navigateToSettings({ section: 'teammates' }), }) + + if (canEdit && pageContext === 'tables') { + list.push( + { + id: 'tables-new-table', + name: 'New table', + keywords: 'create add', + icon: Plus, + context: 'tables', + run: invoke('tables-new-table'), + }, + { + id: 'tables-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'tables', + run: invoke('tables-new-folder'), + }, + { + id: 'tables-import-csv', + name: 'Import CSV', + keywords: 'upload tsv spreadsheet', + icon: Upload, + context: 'tables', + run: invoke('tables-import-csv'), + } + ) + } + if (canEdit && pageContext === 'tableDetail') { + list.push( + { + id: 'table-new-column', + name: 'New column', + keywords: 'create add field', + icon: Columns3, + context: 'tableDetail', + run: invoke('table-new-column'), + }, + { + id: 'table-export-csv', + name: 'Export CSV', + keywords: 'download spreadsheet', + icon: Download, + context: 'tableDetail', + run: invoke('table-export-csv'), + }, + { + id: 'table-import-csv', + name: 'Import CSV', + keywords: 'upload tsv spreadsheet', + icon: Upload, + context: 'tableDetail', + run: invoke('table-import-csv'), + } + ) + } + if (canEdit && pageContext === 'files') { + list.push( + { + id: 'files-new-file', + name: 'New file', + keywords: 'create add document markdown', + icon: File, + context: 'files', + run: invoke('files-new-file'), + }, + { + id: 'files-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'files', + run: invoke('files-new-folder'), + }, + { + id: 'files-upload', + name: 'Upload', + keywords: 'add import file', + icon: Upload, + context: 'files', + run: invoke('files-upload'), + } + ) + } + if (pageContext === 'fileDetail') { + list.push({ + id: 'file-download', + name: 'Download', + keywords: 'save export', + icon: Download, + context: 'fileDetail', + run: invoke('file-download'), + }) + if (canEdit) { + list.push( + { + id: 'file-rename', + name: 'Rename', + keywords: 'edit name', + icon: Pencil, + context: 'fileDetail', + run: invoke('file-rename'), + }, + { + id: 'file-share', + name: 'Share', + keywords: 'link send', + icon: Send, + context: 'fileDetail', + run: invoke('file-share'), + }, + { + id: 'file-delete', + name: 'Delete', + keywords: 'remove trash', + icon: Trash, + context: 'fileDetail', + run: invoke('file-delete'), + } + ) + } + } + if (canEdit && pageContext === 'knowledge') { + list.push( + { + id: 'knowledge-new-base', + name: 'New base', + keywords: 'create add knowledge kb', + icon: Plus, + context: 'knowledge', + run: invoke('knowledge-new-base'), + }, + { + id: 'knowledge-new-folder', + name: 'New folder', + keywords: 'create add group', + icon: FolderPlus, + context: 'knowledge', + run: invoke('knowledge-new-folder'), + } + ) + } + if (canEdit && pageContext === 'knowledgeBase') { + list.push( + { + id: 'knowledge-base-new-documents', + name: 'New documents', + keywords: 'add upload document', + icon: Plus, + context: 'knowledgeBase', + run: invoke('knowledge-base-new-documents'), + }, + { + id: 'knowledge-base-new-connector', + name: 'New connector', + keywords: 'add sync source connect', + icon: Integration, + context: 'knowledgeBase', + run: invoke('knowledge-base-new-connector'), + }, + { + id: 'knowledge-base-rename', + name: 'Rename', + keywords: 'edit name', + icon: Pencil, + context: 'knowledgeBase', + run: invoke('knowledge-base-rename'), + }, + { + id: 'knowledge-base-tags', + name: 'Edit tags', + keywords: 'label metadata', + icon: TagIcon, + context: 'knowledgeBase', + run: invoke('knowledge-base-tags'), + }, + { + id: 'knowledge-base-delete', + name: 'Delete', + keywords: 'remove trash', + icon: Trash, + context: 'knowledgeBase', + run: invoke('knowledge-base-delete'), + } + ) + } + if (pageContext === 'logs' || pageContext === 'logsDashboard') { + list.push({ + id: 'logs-refresh', + name: 'Refresh', + keywords: 'reload update', + icon: RefreshCw, + context: pageContext, + run: invoke('logs-refresh'), + }) + if (canEdit) { + list.push({ + id: 'logs-export', + name: 'Export', + keywords: 'download csv', + icon: Download, + context: pageContext, + run: invoke('logs-export'), + }) + } + list.push( + pageContext === 'logs' + ? { + id: 'logs-show-dashboard', + name: 'Visit dashboard', + keywords: 'charts stats overview', + icon: Library, + context: 'logs', + run: invoke('logs-show-dashboard'), + } + : { + id: 'logs-show-logs', + name: 'Visit logs', + keywords: 'list executions runs', + icon: Library, + context: 'logsDashboard', + run: invoke('logs-show-logs'), + } + ) + } + if (canEdit && pageContext === 'scheduledTasks') { + list.push({ + id: 'scheduled-tasks-new', + name: 'New scheduled task', + keywords: 'create add schedule cron recurring', + icon: Plus, + context: 'scheduledTasks', + run: invoke('scheduled-tasks-new'), + }) + } return list }, [ workspaceId, canEdit, + pageContext, onCreateWorkflow, onCreateFolder, onImportWorkflow, @@ -317,6 +568,24 @@ export function SearchModal({ inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) } inputRef.current.focus() + /** + * cmdk keeps its last selected value across closes and does not re-anchor + * when items mount above it (it only auto-selects when nothing is selected + * yet), so a palette whose top rows appeared after mount — e.g. page + * actions gated on async permissions — would open with a mid-list row + * selected. Home re-selects the first row on every open. + */ + inputRef.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) + /** + * After the frame settles, pin the list back to the very top. cmdk's own + * `scrollIntoView({ block: 'nearest' })` stops as soon as the selected row + * edges into the scrollport, which parks it under the floating search + * input; and without any reset a reopened palette keeps its previous + * scroll offset. + */ + requestAnimationFrame(() => { + if (listRef.current) listRef.current.scrollTop = 0 + }) }, [open]) const deferredSearch = useDeferredValue(search) @@ -326,10 +595,7 @@ export function SearchModal({ const handleSearchChange = useCallback((value: string) => { setSearch(value) requestAnimationFrame(() => { - const list = document.querySelector('[cmdk-list]') - if (list) { - list.scrollTop = 0 - } + if (listRef.current) listRef.current.scrollTop = 0 }) }, []) @@ -347,50 +613,6 @@ export function SearchModal({ return () => document.removeEventListener('keydown', handleKeyDown) }, [open]) - const handleBlockSelect = useCallback( - (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { - const enableTriggerMode = - type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false - window.dispatchEvent( - new CustomEvent('add-block-from-toolbar', { - detail: { - type: block.type, - enableTriggerMode, - pendingConnect: useSearchModalStore.getState().pendingConnect, - }, - }) - ) - captureEvent(posthogRef.current, 'search_result_selected', { - result_type: type, - query_length: deferredSearchRef.current.length, - workspace_id: workspaceId, - }) - onOpenChangeRef.current(false) - }, - [workspaceId] - ) - - const handleToolOperationSelect = useCallback( - (op: SearchToolOperationItem) => { - window.dispatchEvent( - new CustomEvent('add-block-from-toolbar', { - detail: { - type: op.blockType, - presetOperation: op.operationId, - pendingConnect: useSearchModalStore.getState().pendingConnect, - }, - }) - ) - captureEvent(posthogRef.current, 'search_result_selected', { - result_type: 'tool_operation', - query_length: deferredSearchRef.current.length, - workspace_id: workspaceId, - }) - onOpenChangeRef.current(false) - }, - [workspaceId] - ) - const handleWorkflowSelect = useCallback( (workflow: WorkflowItem) => { if (!workflow.isCurrent && workflow.href) { @@ -497,11 +719,11 @@ export function SearchModal({ [workspaceId] ) - const handleDocSelect = useCallback( - (doc: SearchDocItem) => { - window.open(doc.href, '_blank', 'noopener,noreferrer') + const handleLogSelect = useCallback( + (item: LogItem) => { + routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { - result_type: 'docs', + result_type: 'log', query_length: deferredSearchRef.current.length, workspace_id: workspaceId, }) @@ -550,20 +772,31 @@ export function SearchModal({ [workspaceId] ) - const handleBlockSelectAsBlock = useCallback( - (block: SearchBlockItem) => handleBlockSelect(block, 'block'), - [handleBlockSelect] - ) + const handleNewChatFromQuery = useCallback(() => { + const query = deferredSearchRef.current.trim() + if (!query) return - const handleBlockSelectAsTool = useCallback( - (tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'), - [handleBlockSelect] - ) + const homeHref = `/workspace/${workspaceId}/home` + const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) - const handleBlockSelectAsTrigger = useCallback( - (trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'), - [handleBlockSelect] - ) + if (!sentToMountedHome) { + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + logger.warn('Failed to persist command palette query for a new chat', { + workspaceId, + }) + return + } + routerRef.current.push(getMothershipHandoffHref(workspaceId)) + } + + onOpenChangeRef.current(false) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'action', + action_id: 'new-chat-from-query', + query_length: query.length, + workspace_id: workspaceId, + }) + }, [workspaceId]) const handleOverlayClick = useCallback(() => { onOpenChangeRef.current(false) @@ -571,85 +804,40 @@ export function SearchModal({ const entriesBySection = useMemo((): Record => { const query = deferredSearch.trim() - const visibleSections = new Set(displaySections) const rank = ( section: SearchSection, items: T[], toValue: (item: T) => string, toExtra?: (item: T) => string | undefined - ) => { - if (!visibleSections.has(section)) return [] - return query + ) => + query ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra, MAX_RESULTS_PER_GROUP) : items.map((item) => ({ item, score: 0 })) - } const availableActions = actions.filter( - (action) => - action.context === 'global' || - (action.context === 'workflow' && isOnWorkflowPage) || - (action.context === 'integrations' && isOnIntegrationsPage) + (action) => action.context === 'global' || action.context === pageContext ) const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => query ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) : items.map((item) => ({ item, score: 0 })) - const rankedActions = visibleSections.has('actions') - ? [ - ...rankActionGroup( - availableActions.filter((action) => getActionGroupLabel(action) === 'Workflow'), - 'Workflow' - ), - ...rankActionGroup( - availableActions.filter((action) => getActionGroupLabel(action) === 'Platform'), - 'Platform' - ), - ] - : [] - const availableBlocks = isOnWorkflowPage - ? blocks.filter( - (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId - ) - : [] - const availableTools = isOnWorkflowPage - ? tools.filter( - (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId - ) - : [] - const rankedIntegrations = isOnIntegrationsPage - ? rank('integrations', integrations, (item) => item.name) - : [] + const pageGroupLabel = pageContext ? getPageActionGroupLabel(pageContext) : null + const rankedActions = [ + ...(pageGroupLabel + ? rankActionGroup( + availableActions.filter((action) => getActionGroupLabel(action) === pageGroupLabel), + pageGroupLabel + ) + : []), + ...rankActionGroup( + availableActions.filter((action) => getActionGroupLabel(action) === 'Platform'), + 'Platform' + ), + ] return { actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), - connectedAccounts: (isOnIntegrationsPage - ? rank('connectedAccounts', connectedAccounts, (item) => item.name) - : [] - ).map(({ item, score }) => ({ section: 'connectedAccounts', item, score })), - integrations: rankedIntegrations.map(({ item, score }) => ({ - section: 'integrations', - item, - score, - })), - blocks: rank( - 'blocks', - availableBlocks, - (item) => item.name, - (item) => item.searchValue - ).map(({ item, score }) => ({ section: 'blocks', item, score })), - tools: rank( - 'tools', - availableTools, - (item) => item.name, - (item) => item.searchValue - ).map(({ item, score }) => ({ section: 'tools', item, score })), - triggers: rank( - 'triggers', - isOnWorkflowPage ? triggers : [], - (item) => item.name, - (item) => `${item.name} ${item.id}` - ).map(({ item, score }) => ({ section: 'triggers', item, score })), - chats: rank('chats', chats, (item) => item.name).map(({ item, score }) => ({ - section: 'chats', + pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ + section: 'pages', item, score, })), @@ -657,10 +845,10 @@ export function SearchModal({ 'workflows', workflows, (item) => item.name, - (item) => item.folderPath?.join(' ') + (item) => item.folderPath?.map(toSearchToken).join(' ') ).map(({ item, score }) => ({ section: 'workflows', item, score })), - tables: rank('tables', tables, (item) => item.name).map(({ item, score }) => ({ - section: 'tables', + workspaces: rank('workspaces', workspaces, (item) => item.name).map(({ item, score }) => ({ + section: 'workspaces', item, score, })), @@ -668,120 +856,135 @@ export function SearchModal({ 'files', files, (item) => item.name, - (item) => item.folderPath?.join(' ') + (item) => item.folderPath?.map(toSearchToken).join(' ') ).map(({ item, score }) => ({ section: 'files', item, score })), - knowledgeBases: rank('knowledgeBases', knowledgeBases, (item) => item.name).map( - ({ item, score }) => ({ section: 'knowledgeBases', item, score }) - ), - toolOperations: rank( - 'toolOperations', - isOnWorkflowPage ? toolOperations : [], + tables: rank( + 'tables', + tables, (item) => item.name, - (item) => item.searchValue - ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), - workspaces: rank('workspaces', workspaces, (item) => item.name).map(({ item, score }) => ({ - section: 'workspaces', + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'tables', item, score })), + knowledgeBases: rank( + 'knowledgeBases', + knowledgeBases, + (item) => item.name, + (item) => item.folderPath?.map(toSearchToken).join(' ') + ).map(({ item, score }) => ({ section: 'knowledgeBases', item, score })), + logs: rank('logs', logs, (item) => item.name).map(({ item, score }) => ({ + section: 'logs', item, score, })), - docs: rank( - 'docs', - isOnWorkflowPage ? docs : [], - (item) => item.name, - (item) => `${item.name} docs documentation` - ).map(({ item, score }) => ({ section: 'docs', item, score })), - pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ - section: 'pages', + connectedAccounts: rank('connectedAccounts', connectedAccounts, (item) => item.name).map( + ({ item, score }) => ({ section: 'connectedAccounts', item, score }) + ), + integrations: rank('integrations', integrations, (item) => item.name).map( + ({ item, score }) => ({ section: 'integrations', item, score }) + ), + chats: rank('chats', chats, (item) => item.name).map(({ item, score }) => ({ + section: 'chats', item, score, })), } }, [ deferredSearch, - displaySections, actions, - isOnWorkflowPage, - isOnIntegrationsPage, - blocks, - currentWorkflowId, - tools, + pageContext, integrations, connectedAccounts, - triggers, chats, workflows, tables, files, knowledgeBases, - toolOperations, + logs, workspaces, - docs, pages, ]) - const isSearching = Boolean(deferredSearch.trim()) + const searchQuery = deferredSearch.trim() + const isSearching = Boolean(searchQuery) + /** + * Section order for both the browse list and the flat search tie-break: the + * page's own entity section is hoisted directly under `actions`, the rest + * keep the canonical order. + */ + const orderedSections = useMemo((): SearchSection[] => { + const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined + if (!hoisted) return [...SEARCH_SECTIONS] + return [ + 'actions', + hoisted, + ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoisted), + ] + }, [pageContext]) const searchResults = useMemo( - () => (isSearching ? getGlobalSearchResults(entriesBySection, displaySections) : []), - [displaySections, entriesBySection, isSearching] + () => (isSearching ? getGlobalSearchResults(entriesBySection, orderedSections) : []), + [orderedSections, entriesBySection, isSearching] ) - const sectionGroups = useMemo( - () => - displaySections.flatMap((section) => { - const entries = entriesBySection[section] - if (section !== 'actions') { - return [{ key: section, heading: SECTION_LABELS[section], entries }] - } - - const platformEntries = entries.filter( - (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === 'Platform' - ) - const workflowEntries = entries.filter( - (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === 'Workflow' - ) + const showNewChatFallback = isSearching && searchResults.length === 0 && isChatEnabled + const newChatFallbackLabel = `New Chat: ${searchQuery}` + const sectionGroups = useMemo(() => { + const actionEntriesByLabel = (label: ActionGroupLabel) => + entriesBySection.actions.filter( + (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === label + ) + const entityGroup = (section: SearchSection) => ({ + key: section, + heading: SECTION_LABELS[section], + entries: entriesBySection[section], + }) + const pageGroupLabel = pageContext ? getPageActionGroupLabel(pageContext) : null + const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined - return [ - ...(isOnWorkflowPage - ? [{ key: 'workflow-actions', heading: 'Workflow', entries: workflowEntries }] - : []), - { key: 'platform-actions', heading: 'Platform', entries: platformEntries }, - ] - }), - [displaySections, entriesBySection, isOnWorkflowPage] - ) + return [ + ...(pageGroupLabel + ? [ + { + key: 'page-actions', + heading: pageGroupLabel, + entries: actionEntriesByLabel(pageGroupLabel), + }, + ] + : []), + ...(hoisted ? [entityGroup(hoisted)] : []), + { + key: 'platform-actions', + heading: 'Platform', + entries: actionEntriesByLabel('Platform'), + }, + ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoisted).map( + entityGroup + ), + ] + }, [entriesBySection, pageContext]) const entryHandlers = useMemo( (): SearchEntryHandlers => ({ onSelectAction: handleActionSelect, onSelectConnectedAccount: handleConnectedAccountSelect, onSelectIntegration: handleIntegrationSelect, - onSelectBlock: handleBlockSelectAsBlock, - onSelectTool: handleBlockSelectAsTool, - onSelectTrigger: handleBlockSelectAsTrigger, onSelectChat: handleChatSelect, onSelectWorkflow: handleWorkflowSelect, onSelectTable: handleTableSelect, onSelectFile: handleFileSelect, onSelectKnowledgeBase: handleKbSelect, - onSelectToolOperation: handleToolOperationSelect, + onSelectLog: handleLogSelect, onSelectWorkspace: handleWorkspaceSelect, - onSelectDoc: handleDocSelect, onSelectPage: handlePageSelect, }), [ handleActionSelect, handleConnectedAccountSelect, handleIntegrationSelect, - handleBlockSelectAsBlock, - handleBlockSelectAsTool, - handleBlockSelectAsTrigger, handleChatSelect, handleWorkflowSelect, handleTableSelect, handleFileSelect, handleKbSelect, - handleToolOperationSelect, + handleLogSelect, handleWorkspaceSelect, - handleDocSelect, handlePageSelect, ] ) @@ -815,12 +1018,18 @@ export function SearchModal({ }} >
- +
- {isSearching ? ( + {showNewChatFallback ? ( + + ) : isSearching ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index b6b5e711327..6f9f527b700 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -8,7 +8,7 @@ import { fuzzyMatch, getActionGroupLabel, getGlobalSearchResults, - getToolOperationLabel, + getPageActionGroupLabel, MAX_RESULTS_PER_GROUP, type SearchEntry, scoreActions, @@ -24,10 +24,30 @@ describe('getActionGroupLabel', () => { run: () => {}, } - it('separates workflow actions from platform actions', () => { - expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Workflow') + it('separates page actions from platform actions', () => { + expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Workflow Actions') expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Platform') - expect(getActionGroupLabel({ ...action, context: 'integrations' })).toBe('Platform') + }) + + it('groups page actions under their module name', () => { + expect(getActionGroupLabel({ ...action, context: 'tables' })).toBe('Table Actions') + expect(getActionGroupLabel({ ...action, context: 'tableDetail' })).toBe('Table Actions') + expect(getActionGroupLabel({ ...action, context: 'files' })).toBe('File Actions') + expect(getActionGroupLabel({ ...action, context: 'fileDetail' })).toBe('File Actions') + expect(getActionGroupLabel({ ...action, context: 'knowledge' })).toBe('Knowledge Base Actions') + expect(getActionGroupLabel({ ...action, context: 'knowledgeBase' })).toBe( + 'Knowledge Base Actions' + ) + expect(getActionGroupLabel({ ...action, context: 'logs' })).toBe('Logs Actions') + expect(getActionGroupLabel({ ...action, context: 'logsDashboard' })).toBe('Logs Actions') + expect(getActionGroupLabel({ ...action, context: 'scheduledTasks' })).toBe( + 'Scheduled Task Actions' + ) + }) + + it('resolves the same module labels for page contexts directly', () => { + expect(getPageActionGroupLabel('tables')).toBe('Table Actions') + expect(getPageActionGroupLabel('knowledgeBase')).toBe('Knowledge Base Actions') }) it('lets an action group label surface actions whose names do not match', () => { @@ -37,8 +57,10 @@ describe('getActionGroupLabel', () => { context: 'workflow' as const, } - expect(scoreActions([workflowAction], 'workflow', 50, 'Workflow')).toHaveLength(1) - expect(scoreActions([workflowAction], 'platform', 50, 'Workflow')).toHaveLength(0) + expect(scoreActions([workflowAction], 'workflow actions', 50, 'Workflow Actions')).toHaveLength( + 1 + ) + expect(scoreActions([workflowAction], 'platform', 50, 'Workflow Actions')).toHaveLength(0) }) }) @@ -119,67 +141,6 @@ describe('getGlobalSearchResults', () => { 'workflow-0', ]) }) - - it('places selected docs after selected tool operations without changing the result set', () => { - const Icon = () => null - const tool: SearchEntry = { - section: 'tools', - score: 100, - item: { id: 'whatsapp', name: 'WhatsApp', icon: Icon, bgColor: '#25D366', type: 'whatsapp' }, - } - const docs: SearchEntry = { - section: 'docs', - score: 90, - item: { id: 'docs-whatsapp', name: 'WhatsApp', icon: Icon, href: '/whatsapp' }, - } - const operation: SearchEntry = { - section: 'toolOperations', - score: 80, - item: { - id: 'whatsapp_upload_media', - name: 'Upload Media', - serviceName: 'WhatsApp', - searchValue: 'WhatsApp Upload Media', - icon: Icon, - bgColor: '#25D366', - blockType: 'whatsapp', - operationId: 'upload_media', - }, - } - - expect( - getGlobalSearchResults({ tools: [tool], docs: [docs], toolOperations: [operation] }, [ - 'tools', - 'docs', - 'toolOperations', - ]).map((entry) => entry.item.id) - ).toEqual(['whatsapp', 'whatsapp_upload_media', 'docs-whatsapp']) - }) -}) - -describe('getToolOperationLabel', () => { - const Icon = () => null - const operation = { - id: 'whatsapp_send_message', - name: 'Send Message', - serviceName: 'WhatsApp', - searchValue: 'WhatsApp Send Message', - icon: Icon, - bgColor: '#25D366', - blockType: 'whatsapp', - operationId: 'send_message', - } - - it('adds the integration breadcrumb when the integration name matched', () => { - expect(getToolOperationLabel(operation, 'WhatsApp')).toBe('WhatsApp · Send Message') - expect(getToolOperationLabel(operation, 'whats send')).toBe('WhatsApp · Send Message') - }) - - it('keeps the bare operation name for direct operation-name matches', () => { - expect(getToolOperationLabel(operation, 'Send Message')).toBe('Send Message') - expect(getToolOperationLabel(operation, 'message')).toBe('Send Message') - expect(getToolOperationLabel(operation, '')).toBe('Send Message') - }) }) describe('scoreSectionItems', () => { @@ -209,6 +170,41 @@ describe('scoreSectionItems', () => { ).map(({ item }) => item.name) ).toEqual(['Workspaces demo', 'Acme', 'Beta']) }) + + it('lifts a whole section above other sections’ name matches when the query is exactly its name', () => { + const workflowItems = [ + { name: 'Onboarding' }, + { name: 'Billing sync' }, + { name: 'Workflow QA' }, + ] + const sectionScores = scoreSectionItems( + 'workflows', + workflowItems, + (item) => item.name, + 'workflows' + ) + const [chatMatch] = scoreAndSort( + [{ name: 'Workflows retro' }], + (item) => item.name, + 'workflows' + ) + + expect(sectionScores).toHaveLength(3) + expect(sectionScores.every(({ score }) => score > chatMatch.score)).toBe(true) + }) + + it('does not lift a section for a partial section-name query', () => { + const workflowItems = [{ name: 'Onboarding' }] + const [sectionFill] = scoreSectionItems( + 'workflows', + workflowItems, + (item) => item.name, + 'workflow' + ) + const [chatMatch] = scoreAndSort([{ name: 'Workflow retro' }], (item) => item.name, 'workflow') + + expect(sectionFill.score).toBeLessThan(chatMatch.score) + }) }) /** @@ -501,6 +497,75 @@ describe('filterAndSort — name ranked above secondary text', () => { }) }) +describe('secondary-text matching — no scattered noise', () => { + it('does not scatter-match a query across long unrelated secondary text', () => { + const items = [{ name: 'Write Contact', extra: 'Wealthbox Write Contact match snap up' }] + + // The scattered mode alone finds w…h…a…t…s…a…p…p across this extra, so + // without the restriction this item would surface for "whatsapp". + expect(fuzzyMatch(items[0].extra, 'whatsapp').matched).toBe(true) + expect( + filterAndSort( + items, + (item) => item.name, + 'whatsapp', + (item) => item.extra + ) + ).toEqual([]) + }) + + it('still matches secondary text by substring and by whole tokens', () => { + const items = [{ name: 'Send Message', extra: 'Slack Send Message dm chat' }] + + expect( + filterAndSort( + items, + (item) => item.name, + 'slack', + (item) => item.extra + ) + ).toHaveLength(1) + expect( + filterAndSort( + items, + (item) => item.name, + 'slack chat', + (item) => item.extra + ) + ).toHaveLength(1) + expect( + filterAndSort( + items, + (item) => item.name, + 'whatsapp', + (item) => item.extra + ) + ).toHaveLength(0) + }) + + it('scatter-matches within a single kebab-cased entry but never across entries', () => { + const items = [{ name: 'Do Thing', extra: 'slack send-message dm chat' }] + + expect( + filterAndSort( + items, + (item) => item.name, + 'sndmsg', + (item) => item.extra + ) + ).toHaveLength(1) + // "dmchat" needs letters from both the "dm" and "chat" entries — rejected. + expect( + filterAndSort( + items, + (item) => item.name, + 'dmchat', + (item) => item.extra + ) + ).toHaveLength(0) + }) +}) + describe('filterAndCap', () => { const id = (s: string) => s diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 0ddf91fd4e6..df1eb8a5fac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -1,10 +1,28 @@ import type { ComponentType } from 'react' -import type { - SearchBlockItem, - SearchDocItem, - SearchSection, - SearchToolOperationItem, -} from '@/stores/modals/search/types' +import { toSearchToken } from '@/lib/search/tokens' + +/** + * Every result group the palette can render. This is also the canonical order: + * the zero-query browse list and the flat search tie-break both follow it, + * with two page-aware insertions at the front — the page's action group, then + * its own entity section hoisted above `actions`' platform group. + */ +export const SEARCH_SECTIONS = [ + 'actions', + 'pages', + 'workflows', + 'workspaces', + 'files', + 'tables', + 'knowledgeBases', + 'logs', + 'connectedAccounts', + 'integrations', + 'chats', +] as const + +/** A single search-modal result group. */ +export type SearchSection = (typeof SEARCH_SECTIONS)[number] export interface IntegrationSearchItem { id: string @@ -18,17 +36,17 @@ export interface TaskItem { id: string name: string href: string -} - -/** - * A {@link TaskItem} that lives in a folder tree, so the row can show which - * folder it came from — a name is only unique within its folder. - */ -export interface FolderedItem extends TaskItem { + /** Owning folder names, root first. Set for tables and knowledge bases. */ folderPath?: string[] + /** Formatted last-activity date shown as trailing metadata. Set for chats. */ + date?: string } -export interface WorkflowItem extends FolderedItem { +export interface WorkflowItem { + id: string + name: string + href: string + folderPath?: string[] isCurrent?: boolean } @@ -51,10 +69,41 @@ export interface PageItem { hidden?: boolean } -export type FileItem = FolderedItem +export interface FileItem { + id: string + name: string + href: string + folderPath?: string[] +} + +export interface LogItem { + id: string + /** Workflow (or job) name the execution belongs to. */ + name: string + href: string + /** Human-readable run date shown as trailing metadata. */ + date: string +} + +/** + * Pages that contribute their own palette actions while active. Each page + * registers its handlers as global commands on mount; the palette invokes + * them by id and only offers them while the matching route is mounted. + */ +export type PageActionContext = + | 'workflow' + | 'tables' + | 'tableDetail' + | 'files' + | 'fileDetail' + | 'knowledge' + | 'knowledgeBase' + | 'logs' + | 'logsDashboard' + | 'scheduledTasks' /** Where an {@link ActionItem} (a verb) is available. */ -export type ActionContext = 'global' | 'workflow' | 'integrations' +export type ActionContext = 'global' | PageActionContext /** * An action is a verb the palette can run directly (create, import, toggle), @@ -73,11 +122,51 @@ export interface ActionItem { run: () => void } -export type ActionGroupLabel = 'Platform' | 'Workflow' +export type ActionGroupLabel = + | 'Platform' + | 'Workflow Actions' + | 'Table Actions' + | 'File Actions' + | 'Knowledge Base Actions' + | 'Logs Actions' + | 'Scheduled Task Actions' + +const PAGE_CONTEXT_GROUP_LABELS: Record = { + workflow: 'Workflow Actions', + tables: 'Table Actions', + tableDetail: 'Table Actions', + files: 'File Actions', + fileDetail: 'File Actions', + knowledge: 'Knowledge Base Actions', + knowledgeBase: 'Knowledge Base Actions', + logs: 'Logs Actions', + logsDashboard: 'Logs Actions', + scheduledTasks: 'Scheduled Task Actions', +} + +/** + * The page's own entity section, hoisted directly under its action group in + * both the browse list and the search tie-break. + */ +export const PAGE_CONTEXT_HOISTED_SECTION: Partial> = { + tables: 'tables', + tableDetail: 'tables', + files: 'files', + fileDetail: 'files', + knowledge: 'knowledgeBases', + knowledgeBase: 'knowledgeBases', + logs: 'logs', + logsDashboard: 'logs', +} + +/** Heading for a page's contributed action group. */ +export function getPageActionGroupLabel(context: PageActionContext): ActionGroupLabel { + return PAGE_CONTEXT_GROUP_LABELS[context] +} /** Presentation group for an action without changing its stable result identity. */ export function getActionGroupLabel(action: ActionItem): ActionGroupLabel { - return action.context === 'workflow' ? 'Workflow' : 'Platform' + return action.context === 'global' ? 'Platform' : PAGE_CONTEXT_GROUP_LABELS[action.context] } export interface SearchModalProps { @@ -86,13 +175,15 @@ export interface SearchModalProps { workflows?: WorkflowItem[] workspaces?: WorkspaceItem[] chats?: TaskItem[] - tables?: FolderedItem[] + tables?: TaskItem[] files?: FileItem[] - knowledgeBases?: FolderedItem[] + knowledgeBases?: TaskItem[] + logs?: LogItem[] integrations?: IntegrationSearchItem[] connectedAccounts?: IntegrationSearchItem[] isOnWorkflowPage?: boolean - isOnIntegrationsPage?: boolean + /** Page the palette was opened on, when that page contributes actions. */ + pageContext?: PageActionContext | null canEdit?: boolean onCreateWorkflow?: () => void onCreateFolder?: () => void @@ -113,56 +204,46 @@ export interface CommandItemProps { workflowType?: string /** Primary text of the row. */ label: string - /** Right-aligned source section shown in aggregate result groups. */ + /** Right-aligned trailing metadata. */ meta?: string } export const SECTION_LABELS: Record = { actions: 'Platform', - connectedAccounts: 'Connected', - integrations: 'Integrations', - blocks: 'Blocks', - tools: 'Tools', - triggers: 'Triggers', - chats: 'Chats', + pages: 'Pages', workflows: 'Workflows', - tables: 'Tables', - files: 'Files', - knowledgeBases: 'Knowledge bases', - toolOperations: 'Tool operations', workspaces: 'Workspaces', - docs: 'Docs', - pages: 'Pages', + files: 'Files', + tables: 'Tables', + knowledgeBases: 'Knowledge Bases', + logs: 'Logs', + connectedAccounts: 'Connected Integrations', + integrations: 'Integrations', + chats: 'Chats', } export type SearchEntry = | { section: 'actions'; score: number; item: ActionItem } | { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem } - | { section: 'blocks' | 'tools' | 'triggers'; score: number; item: SearchBlockItem } | { section: 'chats'; score: number; item: TaskItem } | { section: 'workflows'; score: number; item: WorkflowItem } | { section: 'tables' | 'knowledgeBases'; score: number; item: TaskItem } | { section: 'files'; score: number; item: FileItem } - | { section: 'toolOperations'; score: number; item: SearchToolOperationItem } + | { section: 'logs'; score: number; item: LogItem } | { section: 'workspaces'; score: number; item: WorkspaceItem } - | { section: 'docs'; score: number; item: SearchDocItem } | { section: 'pages'; score: number; item: PageItem } export interface SearchEntryHandlers { onSelectAction: (item: ActionItem) => void onSelectConnectedAccount: (item: IntegrationSearchItem) => void onSelectIntegration: (item: IntegrationSearchItem) => void - onSelectBlock: (item: SearchBlockItem) => void - onSelectTool: (item: SearchBlockItem) => void - onSelectTrigger: (item: SearchBlockItem) => void onSelectChat: (item: TaskItem) => void onSelectWorkflow: (item: WorkflowItem) => void onSelectTable: (item: TaskItem) => void onSelectFile: (item: FileItem) => void onSelectKnowledgeBase: (item: TaskItem) => void - onSelectToolOperation: (item: SearchToolOperationItem) => void + onSelectLog: (item: LogItem) => void onSelectWorkspace: (item: WorkspaceItem) => void - onSelectDoc: (item: SearchDocItem) => void onSelectPage: (item: PageItem) => void } @@ -192,26 +273,22 @@ export function getGlobalSearchResults( } rankedMatches.sort(compare) - const matches = rankedMatches.map(({ entry }) => entry) - const integrationDetails = [ - ...matches.filter((entry) => entry.section === 'toolOperations'), - ...matches.filter((entry) => entry.section === 'docs'), - ] - let integrationDetailIndex = 0 - - return matches.map((entry) => { - if (entry.section !== 'toolOperations' && entry.section !== 'docs') return entry - const orderedEntry = integrationDetails[integrationDetailIndex] - integrationDetailIndex += 1 - return orderedEntry - }) + return rankedMatches.map(({ entry }) => entry) } +/** + * `scroll-mt-12` mirrors the list's `pt-12`: the search input floats over the + * top 48px of the scrollport, and cmdk keeps the selection visible with + * `scrollIntoView({ block: 'nearest' })` — without the scroll margin, arrowing + * upward (or loop-wrapping to the first row) parks the row under the input. + * Group headings need the same margin because cmdk scrolls the heading into + * view when the selection is its group's first row. + */ export const GROUP_HEADING_CLASSNAME = - '[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]' + '[&_[cmdk-group-heading]]:flex [&_[cmdk-group-heading]]:h-[18px] [&_[cmdk-group-heading]]:items-center [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:mb-2 [&_[cmdk-group-heading]]:scroll-mt-12 [&_[cmdk-group-heading]]:text-small [&_[cmdk-group-heading]]:text-[var(--text-muted)]' export const COMMAND_ITEM_CLASSNAME = - 'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' + 'group mx-0.5 flex h-[30px] w-full cursor-pointer items-center gap-2 rounded-lg border border-transparent px-2 text-left text-sm scroll-mt-12 scroll-mb-1.5 aria-selected:border-[var(--border-1)] aria-selected:bg-[var(--surface-active)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50' /** Characters that begin a new word — a match here scores higher. */ const SEPARATORS = new Set([' ', '-', '_', '/', '.', ':', '(', ')']) @@ -293,8 +370,18 @@ function tokenFallback(lowerText: string, lowerQuery: string): FuzzyResult { * * Contiguous substring matches report the indices of the substring itself * rather than an earlier scattered occurrence of the same characters. + * + * Pass `scatter: false` to skip the scattered-subsequence mode. Over long + * multi-word text (alias lists, option labels) a scattered query matches + * almost anything — "whatsapp" finds `w…h…a…t…s…a…p…p` across unrelated alias + * words — so secondary-text matching keeps only the exact/prefix/substring + * and multi-word token modes. */ -export function fuzzyMatch(text: string, query: string): FuzzyResult { +export function fuzzyMatch( + text: string, + query: string, + options?: { scatter?: boolean } +): FuzzyResult { if (!query) return { matched: true, score: 1, positions: [] } if (!text) return NO_MATCH @@ -322,6 +409,8 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { return { matched: true, score, positions } } + if (options?.scatter === false) return tokenFallback(lowerText, lowerQuery) + const positions: number[] = [] let queryIndex = 0 let score = 0 @@ -355,12 +444,38 @@ export function fuzzyMatch(text: string, query: string): FuzzyResult { /** Rank offset that lifts every name match above any secondary-text match. */ const NAME_MATCH_TIER = 1_000_000 +/** + * Rank offset that lifts an entire section above every name match when the + * query IS that section's name — typing "triggers" asks for the Triggers + * section itself, not rows from other sections that happen to contain the word. + */ +const SECTION_MATCH_TIER = 2_000_000 + /** * Ranks an item by its name first, falling back to secondary text (ids, aliases, * option labels) only when the name doesn't match — a name match always wins, so * an exact name hit isn't diluted by a long secondary string ("Agent" beats * "Pi Coding Agent" for the query "agent"). */ +/** + * Matches a query against secondary search text: a space-separated list of + * entries where multi-word phrases are kebab-cased into single tokens (see + * `toSearchToken`). Whole-string matching keeps the exact/prefix/substring and + * multi-word token modes; scattered matching runs against each entry + * individually, so a query can scatter within one entry ("sndmsg" → + * "send-message") but never assemble itself across unrelated entries + * ("whatsapp" must not match "wealthbox-write-contact match snap up"). + */ +function matchSecondaryText(extra: string, query: string): FuzzyResult { + const whole = fuzzyMatch(extra, query, { scatter: false }) + let best = whole.matched ? whole : NO_MATCH + for (const word of extra.split(/\s+/)) { + const byWord = fuzzyMatch(word, query) + if (byWord.matched && (!best.matched || byWord.score > best.score)) best = byWord + } + return best +} + function scoreItem(name: string, search: string, getExtra?: () => string | undefined): FuzzyResult { const byName = fuzzyMatch(name, search) if (byName.matched) { @@ -368,7 +483,7 @@ function scoreItem(name: string, search: string, getExtra?: () => string | undef } const extra = getExtra?.() if (!extra) return NO_MATCH - const byExtra = fuzzyMatch(extra, search) + const byExtra = matchSecondaryText(extra, search) return byExtra.matched ? byExtra : NO_MATCH } @@ -393,26 +508,11 @@ export function scoreAndSort( return scored } -function matchesIntegrationQuery(integrationName: string, search: string): boolean { - const query = search.trim() - if (!query) return false - - return query.split(/\s+/).some((term) => fuzzyMatch(integrationName, term).matched) -} - -/** Adds integration context only when it, rather than the operation name, matched the query. */ -export function getToolOperationLabel(operation: SearchToolOperationItem, search: string): string { - const query = search.trim() - if (!query || fuzzyMatch(operation.name, query).matched) return operation.name - - return matchesIntegrationQuery(operation.serviceName, query) - ? `${operation.serviceName} · ${operation.name}` - : operation.name -} - /** * Scores normal item matches first, then fills a matched section with its - * remaining rows in natural order. + * remaining rows in natural order. A query that exactly names the section + * lifts every returned row into {@link SECTION_MATCH_TIER}, keeping this + * internal order but beating name matches from other sections. */ function scoreItemsForSection( sectionLabel: string, @@ -423,22 +523,31 @@ function scoreItemsForSection( maxResults = Number.POSITIVE_INFINITY ): Array<{ item: T; score: number }> { const rankedItems = scoreAndSort(items, toValue, search, toExtra) - const sectionMatch = fuzzyMatch(sectionLabel, search.trim()) + const query = search.trim() + const sectionMatch = fuzzyMatch(sectionLabel, query) + const isExactLabelMatch = + sectionMatch.matched && query.toLowerCase() === sectionLabel.toLowerCase() + + let results: Array<{ item: T; score: number }> if (!sectionMatch.matched || rankedItems.length >= maxResults) { - return rankedItems.slice(0, maxResults) + results = rankedItems.slice(0, maxResults) + } else { + const matchedItems = new Set(rankedItems.map(({ item }) => item)) + const lowestItemScore = rankedItems.at(-1)?.score + const fallbackScore = + lowestItemScore === undefined + ? sectionMatch.score + : Math.min(sectionMatch.score, lowestItemScore - 1) + + results = [...rankedItems] + for (const item of items) { + if (!matchedItems.has(item)) results.push({ item, score: fallbackScore }) + if (results.length >= maxResults) break + } } - const matchedItems = new Set(rankedItems.map(({ item }) => item)) - const lowestItemScore = rankedItems.at(-1)?.score - const fallbackScore = - lowestItemScore === undefined - ? sectionMatch.score - : Math.min(sectionMatch.score, lowestItemScore - 1) - - const results = [...rankedItems] - for (const item of items) { - if (!matchedItems.has(item)) results.push({ item, score: fallbackScore }) - if (results.length >= maxResults) break + if (isExactLabelMatch) { + return results.map(({ item }, index) => ({ item, score: SECTION_MATCH_TIER - index })) } return results } @@ -466,7 +575,7 @@ export function scoreActions( actions, (action) => action.name, search, - (action) => `${action.name} ${action.keywords ?? ''}`, + (action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`, maxResults ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index dd44efc80fa..ae9f0e5d3b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -20,33 +20,34 @@ import { Upload, } from '@sim/emcn' import { + BookOpen, + Calendar, Database, Files, + HelpCircle, Integration, - MoreHorizontal, PanelLeft, - Pin, Plus, Search, + Settings, Table, Task, Workflow, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { MoreHorizontal, Pin } from 'lucide-react' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { SlackIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isChatEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' -import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' +import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' -import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' -import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { CollapsedChatFlyoutItem, @@ -57,8 +58,6 @@ import { NavItemContextMenu, SearchModal, SettingsSidebar, - SidebarFooter, - SidebarSection, WorkflowList, WorkspaceHeader, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' @@ -66,11 +65,13 @@ import { buildConnectedAccountSearchItems, buildIntegrationSearchItems, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items' +import type { + LogItem, + PageActionContext, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { - SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, - SIDEBAR_DIVIDER_PAD_BELOW_CLASS, SIDEBAR_ITEM_GAP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' @@ -95,6 +96,7 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' import { useWorkspaceCredentials } from '@/hooks/queries/credentials' import { useFolderMap, useFolders } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { type LogFilters, useLogsList } from '@/hooks/queries/logs' import type { MothershipChatMetadata } from '@/hooks/queries/mothership-chats' import { useDeleteMothershipChat, @@ -115,6 +117,7 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' +import { useFilterStore } from '@/stores/logs/filters/store' import { useSearchModalStore } from '@/stores/modals/search/store' import { useProvidersStore } from '@/stores/providers' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -128,9 +131,29 @@ const logger = createLogger('Sidebar') * invalidate every memo downstream of it. */ const EMPTY_CHATS: MothershipChatMetadata[] = [] -/** Stable identity while a folder list loads, so the search-row memos don't churn. */ const EMPTY_FOLDER_MAP: Record = {} +/** Recent runs shown in the palette's Logs section on the logs pages. */ +const SEARCH_MODAL_LOG_FILTERS: LogFilters = { + timeRange: 'All time', + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + searchQuery: '', + limit: 50, + sortBy: 'date', + sortOrder: 'desc', +} + +/** Short run/activity date for palette row receipts (logs, chats). */ +const SEARCH_MODAL_DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}) + const SLACK_COMMUNITY_URL = 'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA' @@ -158,10 +181,9 @@ export function SidebarTooltip({ ) } -/** Stands in for a chip row while a list loads, so it carries no margin either. */ function SidebarItemSkeleton() { return ( -
+
) @@ -196,13 +218,6 @@ const SidebarChatItem = memo(function SidebarChatItem({ }) { const dragGhostRef = useRef(null) - /** - * The trailing slot fits one glyph, and the dot wins over the pin: it reports - * transient state (a run in progress, or an unread reply elsewhere), while pinning - * is persistent and already conveyed by the row sorting to the top of the list. - */ - const showStatusDot = isActive || (!isCurrentRoute && isUnread) - function handleDragStart(e: React.DragEvent) { e.dataTransfer.effectAllowed = 'copyMove' e.dataTransfer.setData( @@ -246,12 +261,12 @@ const SidebarChatItem = memo(function SidebarChatItem({ >
{chat.name}
{chat.id !== 'new' && ( -
- {showStatusDot && ( +
+ {(isActive || (!isCurrentRoute && isUnread)) && (
)} @@ -369,13 +381,6 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item' const HIDDEN_STYLE = { display: 'none' } as const -/** - * Opts a control out of the desktop shell's window-drag region. The header row is - * draggable chrome, so anything clickable inside it has to say so or the click is - * swallowed by the drag handler. - */ -const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' - /** * Sidebar component with resizable width that persists across page refreshes. * @@ -423,15 +428,9 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() - const { workspace: routeWorkspace } = useWorkspaceHostContext() const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() - const { - config: permissionConfig, - filterBlocks, - isBlockAllowed, - integrationAvailability, - } = usePermissionConfig() - const { navigateToSettings } = useSettingsNavigation() + const { config: permissionConfig, filterBlocks } = usePermissionConfig() + const { navigateToSettings, getSettingsHref } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) const customBlockOverlayVersion = useCustomBlockOverlayVersion() const providers = useProvidersStore((state) => state.providers) @@ -520,8 +519,6 @@ export const Sidebar = memo(function Sidebar({ const { workspaces, - pinnedWorkspaceIds, - toggleWorkspacePin, workspaceCreationPolicy, activeWorkspace, isWorkspacesLoading, @@ -570,17 +567,7 @@ export const Sidebar = memo(function Sidebar({ }) useFolders(workspaceId) - const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId) - // Tables and knowledge bases keep their folders in the generic folder tree, - // keyed by resource type, so each needs its own map to resolve a path. - const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( - permissionConfig.hideTablesTab ? undefined : workspaceId, - 'table' - ) - const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( - permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId, - 'knowledge_base' - ) + const { data: folderMap = {} } = useFolderMap(workspaceId) const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( @@ -754,13 +741,18 @@ export const Sidebar = memo(function Sidebar({ const searchModalWorkflows = useMemo( () => - regularWorkflows.map((workflow) => ({ - id: workflow.id, - name: workflow.name, - href: `/workspace/${workspaceId}/w/${workflow.id}`, - folderPath: getFolderPathNames(folderMap, workflow.folderId), - isCurrent: workflow.id === workflowId, - })), + regularWorkflows.map((workflow) => { + const folderPath = workflow.folderId + ? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name) + : [] + return { + id: workflow.id, + name: workflow.name, + href: `/workspace/${workspaceId}/w/${workflow.id}`, + folderPath: folderPath.length > 0 ? folderPath : undefined, + isCurrent: workflow.id === workflowId, + } + }), [regularWorkflows, folderMap, workspaceId, workflowId] ) @@ -790,18 +782,29 @@ export const Sidebar = memo(function Sidebar({ // on a workflow the server declined to create. hidden: !isChatEnabled && !permissionsLoading && !canEdit, }, + { + id: 'search', + label: 'Search', + icon: Search, + onClick: openSearchModal, + }, { id: 'integrations', label: 'Integrations', icon: Integration, href: `/workspace/${workspaceId}/integrations`, - /* Skills is a tab of this surface, not its own nav item — keep the entry - lit while the user is on it. */ additionalActivePaths: [`/workspace/${workspaceId}/skills`], hidden: permissionConfig.hideIntegrationsTab, }, ].filter((item) => !item.hidden), - [workspaceId, createWorkflow, canEdit, permissionsLoading, permissionConfig.hideIntegrationsTab] + [ + workspaceId, + openSearchModal, + createWorkflow, + canEdit, + permissionsLoading, + permissionConfig.hideIntegrationsTab, + ] ) const workspaceNavItems = useMemo( @@ -823,11 +826,18 @@ export const Sidebar = memo(function Sidebar({ }, { id: 'knowledge-base', - label: 'Knowledge bases', + label: 'Knowledge base', icon: Database, href: `/workspace/${workspaceId}/knowledge`, hidden: permissionConfig.hideKnowledgeBaseTab, }, + { + id: 'scheduled-tasks', + label: 'Scheduled tasks', + icon: Calendar, + href: `/workspace/${workspaceId}/scheduled-tasks`, + hidden: !isChatEnabled, + }, { id: 'logs', label: 'Logs', @@ -843,14 +853,22 @@ export const Sidebar = memo(function Sidebar({ ] ) - const handleOpenSettings = useCallback( - (section: SettingsSection) => { - if (!isCollapsedRef.current) { - setSidebarWidth(SIDEBAR_WIDTH.MIN) - } - navigateToSettings({ section }) - }, - [navigateToSettings, setSidebarWidth] + const footerItems = useMemo( + () => [ + { + id: 'settings', + label: 'Settings', + icon: Settings, + href: getSettingsHref(), + onClick: () => { + if (!isCollapsedRef.current) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + navigateToSettings() + }, + }, + ], + [navigateToSettings, getSettingsHref, setSidebarWidth] ) const { data: fetchedChats = EMPTY_CHATS, isLoading: chatsLoading } = useMothershipChats( @@ -869,6 +887,7 @@ export const Sidebar = memo(function Sidebar({ fetchedChats.map((t) => ({ ...t, href: `/workspace/${workspaceId}/chat/${t.id}`, + date: SEARCH_MODAL_DATE_FORMAT.format(t.updatedAt), })), [fetchedChats, workspaceId] ) @@ -876,17 +895,27 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedTables = [] } = useTablesList(workspaceId) const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId) const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId) + const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId, 'table') + const { data: knowledgeFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + workspaceId, + 'knowledge_base' + ) const searchModalTables = useMemo( () => permissionConfig.hideTablesTab ? [] - : fetchedTables.map((t) => ({ - id: t.id, - name: t.name, - href: `/workspace/${workspaceId}/tables/${t.id}`, - folderPath: getFolderPathNames(tableFolderMap, t.folderId), - })), + : fetchedTables.map((t) => { + const folderPath = t.folderId + ? getFolderPath(tableFolderMap, t.folderId).map((folder) => folder.name) + : [] + return { + id: t.id, + name: t.name, + href: `/workspace/${workspaceId}/tables/${t.id}`, + folderPath: folderPath.length > 0 ? folderPath : undefined, + } + }), [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab] ) @@ -907,18 +936,18 @@ export const Sidebar = memo(function Sidebar({ () => permissionConfig.hideKnowledgeBaseTab ? [] - : fetchedKnowledgeBases.map((kb) => ({ - id: kb.id, - name: kb.name, - href: `/workspace/${workspaceId}/knowledge/${kb.id}`, - folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId), - })), - [ - fetchedKnowledgeBases, - knowledgeBaseFolderMap, - workspaceId, - permissionConfig.hideKnowledgeBaseTab, - ] + : fetchedKnowledgeBases.map((kb) => { + const folderPath = kb.folderId + ? getFolderPath(knowledgeFolderMap, kb.folderId).map((folder) => folder.name) + : [] + return { + id: kb.id, + name: kb.name, + href: `/workspace/${workspaceId}/knowledge/${kb.id}`, + folderPath: folderPath.length > 0 ? folderPath : undefined, + } + }), + [fetchedKnowledgeBases, knowledgeFolderMap, workspaceId, permissionConfig.hideKnowledgeBaseTab] ) const chatIds = useMemo(() => chats.map((t) => t.id), [chats]) @@ -1056,6 +1085,7 @@ export const Sidebar = memo(function Sidebar({ ) const [hasOverflowTop, setHasOverflowTop] = useState(false) + const [hasOverflowBottom, setHasOverflowBottom] = useState(false) useEffect(() => { const container = scrollContainerRef.current @@ -1063,6 +1093,9 @@ export const Sidebar = memo(function Sidebar({ const updateScrollState = () => { setHasOverflowTop(container.scrollTop > 1) + setHasOverflowBottom( + container.scrollHeight > container.scrollTop + container.clientHeight + 1 + ) } updateScrollState() @@ -1080,26 +1113,57 @@ export const Sidebar = memo(function Sidebar({ }, []) const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false - const isOnIntegrationsPage = - pathname?.startsWith(`/workspace/${workspaceId}/integrations`) ?? false + + const logsViewMode = useFilterStore((state) => state.viewMode) + + /** + * Page whose registered palette commands are currently invocable. Matches + * only routes that mount the registering component: list pages exactly, and + * detail roots as a single path segment (deeper routes don't mount them). + */ + const searchModalPageContext = useMemo((): PageActionContext | null => { + if (!pathname) return null + if (workflowId) return 'workflow' + const base = `/workspace/${workspaceId}` + const detailSegment = (prefix: string): string | null => { + if (!pathname.startsWith(prefix)) return null + const rest = pathname.slice(prefix.length) + return rest && !rest.includes('/') ? rest : null + } + if (pathname === `${base}/tables`) return 'tables' + if (detailSegment(`${base}/tables/`)) return 'tableDetail' + if (pathname === `${base}/files`) return 'files' + if (detailSegment(`${base}/files/`)) return 'fileDetail' + if (pathname === `${base}/knowledge`) return 'knowledge' + if (detailSegment(`${base}/knowledge/`)) return 'knowledgeBase' + if (pathname === `${base}/logs`) return logsViewMode === 'dashboard' ? 'logsDashboard' : 'logs' + if (pathname === `${base}/scheduled-tasks`) return 'scheduledTasks' + return null + }, [pathname, workspaceId, workflowId, logsViewMode]) const { data: fetchedCredentials = [] } = useWorkspaceCredentials({ workspaceId, - enabled: isOnIntegrationsPage && !permissionConfig.hideIntegrationsTab, + enabled: !permissionConfig.hideIntegrationsTab, }) + const isOnLogsPage = + searchModalPageContext === 'logs' || searchModalPageContext === 'logsDashboard' + const logsPages = useLogsList(workspaceId, SEARCH_MODAL_LOG_FILTERS, { enabled: isOnLogsPage }) + const searchModalLogs = useMemo((): LogItem[] => { + const rows = logsPages.data?.pages[0]?.logs ?? [] + return rows.map((log) => ({ + id: log.id, + name: log.workflow?.name || log.jobTitle || 'Unknown workflow', + href: log.executionId + ? `/workspace/${workspaceId}/logs?executionId=${log.executionId}` + : `/workspace/${workspaceId}/logs`, + date: SEARCH_MODAL_DATE_FORMAT.format(new Date(log.createdAt)), + })) + }, [logsPages.data, workspaceId]) + const searchModalIntegrations = useMemo( - () => - permissionConfig.hideIntegrationsTab - ? [] - : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => { - const availability = integrationAvailability.get(blockType.toLowerCase()) - if (!availability) return CONNECT_MODE.oauth - if (availability?.oauthAvailable) return CONNECT_MODE.oauth - if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount - return null - }), - [workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability] + () => (permissionConfig.hideIntegrationsTab ? [] : buildIntegrationSearchItems(workspaceId)), + [workspaceId, permissionConfig.hideIntegrationsTab] ) const searchModalConnectedAccounts = useMemo( @@ -1338,7 +1402,7 @@ export const Sidebar = memo(function Sidebar({ />
{isOnSettingsPage ? ( @@ -1445,8 +1475,7 @@ export const Sidebar = memo(function Sidebar({ className={cn( SIDEBAR_SECTION_GAP_CLASS, SIDEBAR_ITEM_GAP_CLASS, - SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, - 'flex flex-shrink-0 flex-col px-2' + 'flex flex-shrink-0 flex-col px-2 pb-1.5' )} > {topNavItems.map((item) => ( @@ -1463,23 +1492,22 @@ export const Sidebar = memo(function Sidebar({
{isChatEnabled && ( - +
+
+
Chats
+
{isCollapsed ? ( {chatsLoading ? ( @@ -1510,7 +1538,7 @@ export const Sidebar = memo(function Sidebar({ )} ) : ( -
+
{chatsLoading ? ( ) : ( @@ -1521,10 +1549,10 @@ export const Sidebar = memo(function Sidebar({
) : null} {/* `selectChatOnly` populates `selectedChats` on every click, so - a single entry just means "last clicked" — already conveyed by - `isCurrentRoute`. Highlight from selection only for explicit - multi-selection (size > 1), otherwise it lingers after navigating - away from a chat. */} + a single entry just means "last clicked" — already conveyed by + `isCurrentRoute`. Highlight from selection only for explicit + multi-selection (size > 1), otherwise it lingers after navigating + away from a chat. */} {chats.slice(0, visibleChatCount).map((chat) => { const isCurrentRoute = pathname === chat.href const isRenaming = chatFlyoutRename.editingId === chat.id @@ -1591,14 +1619,13 @@ export const Sidebar = memo(function Sidebar({ )}
)} - +
)} - +
+
+
Workspace
+
{workspaceNavItems.map((item) => ( ))}
- - - + +
+
+
Workflows
+ {!isCollapsed && (
@@ -1625,13 +1655,13 @@ export const Sidebar = memo(function Sidebar({ @@ -1665,7 +1695,7 @@ export const Sidebar = memo(function Sidebar({
- ) - } - > + )} +
{isCollapsed ? ( {workflowsLoading && regularWorkflows.length === 0 ? ( @@ -1746,7 +1776,7 @@ export const Sidebar = memo(function Sidebar({ )} ) : ( -
+
{workflowsLoading && regularWorkflows.length === 0 ? ( ) : ( @@ -1764,19 +1794,58 @@ export const Sidebar = memo(function Sidebar({ )}
)} - +
- +
+ + + + + + + + + + Docs + + + + Slack Community + + + + Report an issue + + + + + {footerItems.map((item) => ( + + ))} +
{ const searchValue = buildCommandSearchableOptionSearchValue(block) expect(searchValue).toContain('Provider') - expect(searchValue).toContain('Fal.ai (Multi-Model)') + expect(searchValue).toContain('Fal.ai-(Multi-Model)') expect(searchValue).toContain('falai') expect(searchValue).not.toContain('Hidden Provider') expect(searchValue).not.toContain('hidden') @@ -144,7 +144,7 @@ describe('search modal store', () => { expect(tools[0]).toEqual( expect.objectContaining({ id: 'image_generator_v2', - searchValue: expect.stringContaining('Fal.ai (Multi-Model)'), + searchValue: expect.stringContaining('Fal.ai-(Multi-Model)'), }) ) }) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 0ed810a8b69..1086e815c37 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -1,6 +1,7 @@ import { Repeat, Split } from '@sim/emcn/icons' import { create } from 'zustand' import { devtools } from 'zustand/middleware' +import { toSearchToken } from '@/lib/search/tokens' import { getToolOperationsIndex } from '@/lib/search/tool-operations' import { getTriggersForSidebar } from '@/lib/workflows/triggers/trigger-utils' import { getAllBlocks } from '@/blocks' @@ -54,8 +55,8 @@ export function buildCommandSearchableOptionSearchValue(block: BlockConfig): str if (option.hidden) continue const subBlockTitle = subBlock.title ?? subBlock.id - terms.add(subBlockTitle) - terms.add(option.label) + terms.add(toSearchToken(subBlockTitle)) + terms.add(toSearchToken(option.label)) terms.add(option.id) } } @@ -67,24 +68,18 @@ export const useSearchModalStore = create()( devtools( (set, _) => ({ isOpen: false, - sections: null, - pendingConnect: null, data: initialData, setOpen: (open: boolean) => { - set({ isOpen: open, sections: null, pendingConnect: null }) + set({ isOpen: open }) }, - open: (options) => { - set({ - isOpen: true, - sections: options?.sections ?? null, - pendingConnect: options?.pendingConnect ?? null, - }) + open: () => { + set({ isOpen: true }) }, close: () => { - set({ isOpen: false, sections: null, pendingConnect: null }) + set({ isOpen: false }) }, initializeData: (filterBlocks) => { @@ -104,7 +99,7 @@ export const useSearchModalStore = create()( icon: block.icon, bgColor: block.bgColor || '#6B7280', type: block.type, - searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`, + searchValue: `${toSearchToken(block.name)} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`, sourceWorkflowId: block.sourceWorkflowId, } @@ -176,12 +171,14 @@ export const useSearchModalStore = create()( const toolOperations: SearchToolOperationItem[] = getToolOperationsIndex() .filter((op) => allowedBlockTypes.has(op.blockType)) .map((op) => { - const aliasesStr = op.aliases?.length ? ` ${op.aliases.join(' ')}` : '' + const aliasesStr = op.aliases?.length + ? ` ${op.aliases.map(toSearchToken).join(' ')}` + : '' return { id: op.id, name: op.operationName, serviceName: op.serviceName, - searchValue: `${op.serviceName} ${op.operationName}${aliasesStr}`, + searchValue: `${toSearchToken(op.serviceName)} ${toSearchToken(op.operationName)}${aliasesStr}`, icon: op.icon, bgColor: op.bgColor, blockType: op.blockType, diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index 4b876a02a13..5d907c8d9d9 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -53,36 +53,8 @@ export interface SearchData { } /** - * Every result group the search modal can render, in render order. Used to - * restrict the palette to a subset of sections when opened for a specific - * intent (e.g. a drag-release that should only offer canvas-insertable items). - */ -export const SEARCH_SECTIONS = [ - 'actions', - 'connectedAccounts', - 'integrations', - 'triggers', - 'chats', - 'workflows', - 'tables', - 'files', - 'knowledgeBases', - 'blocks', - 'tools', - 'toolOperations', - 'workspaces', - 'docs', - 'pages', -] as const - -/** A single search-modal result group. */ -export type SearchSection = (typeof SEARCH_SECTIONS)[number] - -/** - * Context handed to the palette when it is opened to complete an edge - * drag-release: the dragged source handle and the release point. A selection - * stamps it onto its event so the canvas places the block at the drop point and - * wires it from that handle. + * Context handed to the connection block selector when it opens to complete an + * edge drag-release: the dragged source handle and the release point. */ export interface PendingConnect { source: { nodeId: string; handleId: string } @@ -95,43 +67,23 @@ export interface PendingConnect { * * Centralizing this state in a store allows any component (e.g. sidebar, * workflow command list, keyboard shortcuts) to open or close the modal - * without relying on DOM events or prop drilling. + * without relying on DOM events or prop drilling. The pre-computed block data + * also feeds the canvas connection block selector. */ export interface SearchModalState { /** Whether the search modal is currently open. */ isOpen: boolean - /** - * When set, the palette renders only these sections; `null` shows all of them. - */ - sections: SearchSection[] | null - - /** - * Pending edge drag-release the palette was opened to complete. A selection - * stamps it onto its event; other add-block dispatchers carry none, so only a - * genuine palette pick completes the connection. `null` for ordinary opens. - */ - pendingConnect: PendingConnect | null - - /** Pre-computed search data. */ + /** Pre-computed block/tool search data (consumed by the canvas selector). */ data: SearchData - /** - * Explicitly set the open state of the modal. Always resets to the full - * palette (no section restriction, no pending connect). - */ + /** Explicitly set the open state of the modal. */ setOpen: (open: boolean) => void - /** - * Convenience method to open the modal. Pass `sections` to restrict the - * palette to a subset of result groups, and `pendingConnect` to complete an - * edge drag-release with the selection. - */ - open: (options?: { sections?: SearchSection[]; pendingConnect?: PendingConnect }) => void + /** Convenience method to open the modal. */ + open: () => void - /** - * Convenience method to close the modal. - */ + /** Convenience method to close the modal. */ close: () => void /** diff --git a/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html b/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html new file mode 100644 index 00000000000..889f5b64530 --- /dev/null +++ b/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html @@ -0,0 +1,536 @@ + + + + + + Ideation: Context-Aware Command Palette + + + +
+
+

Repo-grounded ideation

+

Context-aware command palette

+

Cmd+K can become the fastest way to continue work on the current page—not merely a global directory. The strongest direction layers page-native actions, scoped entities, and result-owned operations without bloating the palette.

+ + + +
+
25consolidated raw candidates
+
6ranked directions
+
5topic axes covered
+
+
+ +
+

Grounding Context

+

Codebase Context

+

The opportunity is not hypothetical: most of the useful verbs, state, queries, and permission checks already exist on their owning pages. Cmd+K currently lacks the contribution seam that would project them consistently.

+ +
+
+ Context exists, but it is coarse +

The palette distinguishes global, workflow, and integrations. Canvas actions prove the page-aware model; KBs, Tables, Files, Logs, and Scheduled Tasks have not yet joined it.

+
+
+ Deep entities are already queryable +

KB documents/connectors and table rows/views already have scoped server queries. Executions, schedules, credentials, files, and folders already carry stable identity and useful metadata.

+
+
+ Selection is mature page state +

Files and KB documents preserve multi-selection, understand cardinality, and expose bulk handlers. Cmd+K can borrow this state instead of asking users to rediscover the same objects.

+
+
+ Legality belongs to the page +

Existing actions already vary by permission and object state: Retry versus Cancel, Pause versus Resume, editable versus read-only. The palette should not duplicate those decisions.

+
+
+
+ +
+

Topic Axes

+

Five ways the palette can become contextual

+
+
Page-local verbs

What can I do here, before I type?

+
Page-native entities

What finer-grained objects become searchable here?

+
Object and selection context

What am I already working on?

+
Result evidence and preview

How do I know this is the right match and act on it?

+
Contribution, routing, and permissions

How can pages add capability without turning SearchModal into a monolith?

+
+
+ +
+

Ranked Ideas

+

Six directions worth pursuing

+

The ranking favors immediate user value and reuse of existing page capabilities, while preserving an architecture that can scale to every surface.

+ + + +
+ 1. +

Contextual Next Moves

+

Description Treat the current page, active object, and object state as an implicit zero-query. Cmd+K on Tables should begin with New table, New folder, and Import CSV; inside a KB it should begin with Add document, Add connector, or Sync; Files should offer Upload and New folder. Generic platform actions remain available, but page actions get the first positions.

+

Axis Page-local verbs

+

Basis direct: Tables, KBs, Files, Logs, and Scheduled Tasks already own guarded action handlers, while Cmd+K already proves the model with workflow-specific actions in search-modal/utils.ts.

+

Rationale Zero-query becomes useful without requiring users to remember command names. It is the smallest change that makes the palette feel aware of the place where it was opened.

+

Downsides Ordering can become noisy if every page promotes too many verbs. Each surface needs a deliberately small “next moves” set, not a mirror of every menu.

+
+
Confidence
95%
+
Complexity
Medium
+
Best first proof
Tables + KB detail
+
+ +
+ + The context stack that determines zero-query actions + The palette combines page, active object, selection, and state, then ranks contextual actions above platform fallback actions. + + + + + + + + PageTables / KBs + + ObjectCustomers / HR + + Selection12 documents + + StateFailed / paused + + + Contextual next moves + then platform fallback + Implicit query assembled before the user types + + +
Illustrative direction: four existing kinds of context combine into a small, ranked page-action set.
+
+
+ +
+ 2. +

Semantic Zoom Search

+

Description Search becomes more detailed as context becomes more specific. Globally, Cmd+K returns top-level workspace resources. Inside a KB, it can return documents and connectors; inside a table, saved views and matching rows; on Logs or Scheduled Tasks, executions and schedules. The inferred scope is visible and reversible, and deep results resolve asynchronously only after the query expresses intent.

+

Axis Page-native searchable entities

+

Basis direct: KB document and connector queries already exist in hooks/queries/kb/knowledge.ts and connectors.ts; table all-cell search and saved views already exist in hooks/queries/tables.ts. The palette currently receives pre-hydrated section arrays and caps groups to avoid per-keystroke stalls.

+

Rationale People often remember content rather than its container. Scoped resolution unlocks that recall path without imposing a workspace-wide data-loading tax.

+

Downsides Federated async results complicate loading, cancellation, ranking, and keyboard stability. Table row search should preserve its current explicit-submit behavior rather than firing expensive searches on every character.

+
+
Confidence
90%
+
Complexity
High
+
Best first proof
KB documents, then table rows
+
+ + + + + + + + + + +
SurfacePage actionsScoped result typesUseful trailing evidence
Knowledge BaseAdd document, Add connector, SyncDocuments, connectorsSource, freshness, enabled state
TablesNew table, Import CSV, ExportSaved views, matching rowsMatched column and cell
FilesUpload, New folder, New fileFiles, foldersPath, type, modified time
LogsRefresh, ExportExecutionsStatus, workflow, duration
Scheduled TasksNew scheduled taskSchedulesCadence, next run, paused state
+
+ +
+ 3. +

Selection Is the Command Target

+

Description When Cmd+K opens over one or many selected objects, the selection becomes its active operand. Four selected files can offer Move, Download, or Delete; twelve KB documents can offer Enable, Disable, or Delete. Commands that require a single object disappear or explain why they are unavailable.

+

Axis Current-object and multi-selection context

+

Basis direct: Files and KB documents already hold Set-based selection, distinguish single from bulk actions, and dispatch cardinality-aware handlers in their page and context-menu components.

+

Rationale This turns Cmd+K into a keyboard-native complement to context menus. It accelerates work the user has already framed instead of resetting them to a global browser.

+

Downsides The palette must make its operand obvious—e.g. “12 documents selected”—and define what happens when route or selection changes while it is open.

+
+
Confidence
92%
+
Complexity
Medium
+
Best first proof
KB document bulk actions
+
+
+ +
+ 4. +

Actionable Match Receipts

+

Description Preserve compact rows, but make each result answer “why is this the right one?” The right side can show path, source, status, cadence, or result kind. The highlighted result can reveal a document excerpt, matching table cell, execution error, or connector provenance. A secondary action mode then offers result-owned operations such as Open, Copy link, Export, Retry, Pause, or Reconnect.

+

Axis Result presentation and preview

+

Basis direct: The current command row already has a trailing metadata slot, while native KB cards and execution previews already encode descriptions, provenance, status, and duration. Table, log, and schedule menus already define useful legal actions for their entities.

+

Rationale Deep search is only useful when users can distinguish similar matches and complete common operations without a navigation round-trip.

+

Downsides Evidence and action modes add focus and keyboard complexity. Start with typed trailing receipts, then highlighted evidence, then an action panel; do not make a wide Peek panel a prerequisite.

+
+
Confidence
88%
+
Complexity
Medium
+
Best first proof
Execution + KB document rows
+
+
+ +
+ 5. +

Operational Results

+

Description Use Logs and Scheduled Tasks as the proving ground for stateful results rather than static links. Failed executions can expose Retry and Open snapshot; running executions can expose Cancel; schedules can show cadence and next run with Pause or Resume. Integrations can later use the same pattern for Reconnect and connector Sync.

+

Axis Result presentation and page-native entities

+

Basis direct: Log actions already vary by execution state, schedule actions vary by lifecycle and recurrence, and credentials are already individually addressable search entities. The state and legal interventions exist today.

+

Rationale Operational users search because something needs attention. Status-aware results compress detection, navigation, and intervention into one interaction, giving this surface a clearer payoff than adding another passive page link.

+

Downsides Avoid a global “needs attention” feed initially—it would require cross-surface fetching or polling. Begin within Logs and Scheduled Tasks, where the data is already present and scoped.

+
+
Confidence
84%
+
Complexity
High
+
Best first proof
Logs: Retry / Cancel
+
+
+ +
+ 6. +

Page-Owned Command Contributions

+

Description Let each mounted page contribute a small command and result-resolver contract: label, applicable resource, visible, enabled, disabledReason, permission check, analytics identity, and execution behavior. The page remains authoritative for legality and data fetching; Cmd+K owns aggregation, ranking, rendering, focus, and global fallback.

+

Axis Contribution, routing, and permissions architecture

+

Basis direct: The workspace global-command provider already registers and invokes mounted handlers by ID. Cmd+K currently has coarse contexts and centrally assembled permission props, while deep domain queries already live in typed, cancellable React Query hooks.

+

Rationale This is the leverage point that lets every page become contextual without teaching SearchModal each page’s business logic or loading every possible entity up front.

+

Downsides A generic contract can become over-abstract before two real pages prove it. Build it from Tables and KB requirements, then test it with Logs before standardizing the API.

+
+
Confidence
93%
+
Complexity
High
+
Best first proof
Tables + KB contributions
+
+ +
+ Recommended architecture sequence +

Ship one page-action contribution first, add one scoped async resolver second, then add selection and result-owned actions. This keeps the contract shaped by real product needs.

+
+
+
+ +
+

Rejection Summary

+

What was cut, merged, or deferred

+

The cuts favor a contextual core that can be proven incrementally. Ambitious parsing, indexing, and cross-route continuation should follow evidence that people use the simpler model.

+
+ + + + + + + + + + + + + + + + + +
#IdeaReason rejected
1Current-object actionsMerged into Contextual Next Moves as the object-specific version of the same zero-query behavior.
2Page-local recents / ContinueNo shared recency or unfinished-work feed exists; page/object/state context is better grounded.
3KB deep searchMerged into Semantic Zoom Search as the strongest first pilot.
4Table deep searchMerged into Semantic Zoom Search as the second pilot, preserving explicit-submit search.
5Files deep searchFiles and folder paths are already searchable; richer path evidence belongs in Match Receipts.
6Explicit scope controlMerged into Semantic Zoom Search as a required interaction detail rather than a separate bet.
7Needs-attention groupDeferred: it requires cross-surface fetching or polling and is a ranking layer over operational entities.
8Wide Peek panelDeferred: substantial shell and layout cost before compact receipts and highlighted evidence are proven insufficient.
9Natural-language query compilerNo shared query-to-filter grammar exists; ambiguity and per-page implementation cost are high.
10Relationship searchNo general relationship index or cross-entity query layer exists yet.
11Search-to-createGeneric query-to-draft validation and handoff are not established across page types.
12Route-carried intentScope overrun: unmounted handlers currently fail and no general pending-intent protocol exists.
13Separate permission-execution layerMerged into Page-Owned Command Contributions; legality must be a core contract property, not a parallel system.
+
+
+ +
Composed 2026-08-05T02:32Z by ce-ideate from the in-session Cmd+K page-context prompt and the current feat-enhance-cmd-k worktree.
+
+ + From 10917830682882e2b098b468313b6b4c20e2abcc Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:00:35 -0700 Subject: [PATCH 12/29] feat(search): ask-Sim tab mode, canvas sections, and palette refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab now flips the palette into ask mode — Enter lands on Chat with the query seeded via the proven curated-prompt handoff (auto-send deferred on a diagnosed use-chat mount-abort bug) — replacing the no-results New Chat fallback. Restores the canvas Blocks/Triggers/Tools/Tool operations sections between the workflow Actions group and Sim, keeps the integrations catalog and connected accounts off the canvas, renames the global group to Sim and page groups to Actions, puts an exactly-named page above its lifted contents, adds chat last-activity receipts, and softens the list chrome (hidden scrollbar, shorter fade, scroll-margin fixes for arrow and loop navigation). Also renames the generic webhook block to Webhook. Co-Authored-By: Claude Fable 5 --- .../panel/components/toolbar/toolbar.tsx | 4 +- .../command-chrome/command-chrome.tsx | 15 +- .../command-items/command-items.tsx | 9 +- .../search-groups/search-groups.tsx | 50 ++++ .../search-modal/search-modal.test.tsx | 263 ++++++++++++++---- .../components/search-modal/search-modal.tsx | 240 +++++++++++++--- .../components/search-modal/utils.test.ts | 36 +-- .../sidebar/components/search-modal/utils.ts | 63 +++-- apps/sim/blocks/blocks/generic_webhook.ts | 2 +- apps/sim/lib/posthog/events.ts | 1 + apps/sim/stores/modals/search/store.ts | 2 +- 11 files changed, 523 insertions(+), 162 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 5cdfdda7f5a..a436a6ddfbb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -183,14 +183,14 @@ function syncCachesToOverlayVersion(version: number) { /** * Gets triggers data, computing it once per overlay version and caching for - * subsequent calls. Non-integration triggers (Start, Schedule, Webhook Trigger) are + * subsequent calls. Non-integration triggers (Start, Schedule, Webhook) are * prioritized first, followed by all other triggers sorted alphabetically. */ function getTriggers(overlayVersion: number): BlockItem[] { syncCachesToOverlayVersion(overlayVersion) if (cachedTriggers === null) { const allTriggers = getTriggersForSidebar() - const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] + const priorityOrder = ['Start', 'Schedule', 'Webhook'] const sortedTriggers = allTriggers.sort((a, b) => { const aIndex = priorityOrder.indexOf(a.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 6f99f2b52e9..6f08b40d523 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -1,6 +1,11 @@ 'use client' -import { type ComponentPropsWithoutRef, forwardRef, type KeyboardEvent } from 'react' +import { + type ComponentPropsWithoutRef, + forwardRef, + type KeyboardEvent, + type ReactNode, +} from 'react' import { cn } from '@sim/emcn' import { Command } from 'cmdk' import { Search } from 'lucide-react' @@ -11,6 +16,8 @@ type CommandListProps = ComponentPropsWithoutRef interface CommandSearchProps extends Omit { surface: 'canvas' | 'palette' cycleResultsOnTab?: boolean + /** Trailing slot after the input (e.g. a mode hint). Non-interactive. */ + endAdornment?: ReactNode } interface CommandFadedListProps extends CommandListProps { @@ -39,7 +46,10 @@ const LIST_FADE_CLASSNAME = { /** Borderless search field layered over a fading command-result list. */ export const CommandSearch = forwardRef( - function CommandSearch({ surface, cycleResultsOnTab = false, onKeyDown, ...props }, ref) { + function CommandSearch( + { surface, cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, + ref + ) { const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event) if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return @@ -68,6 +78,7 @@ export const CommandSearch = forwardRef( onKeyDown={handleKeyDown} {...props} /> + {endAdornment}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index a48329b5d64..2fef47d2ee4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -92,6 +92,7 @@ export const MemoizedCommandItem = memo( showColoredIcon, workflowType, label, + labelPrefix, meta, }: CommandItemProps) { const workflowAccent = workflowType ? getMappedWorkflowTypeAccent(workflowType) : null @@ -121,8 +122,11 @@ export const MemoizedCommandItem = memo( />
)} - {label} - {meta && } + + {labelPrefix && {labelPrefix} } + {label} + + {meta ? : null} ) }, @@ -133,6 +137,7 @@ export const MemoizedCommandItem = memo( prev.showColoredIcon === next.showColoredIcon && prev.workflowType === next.workflowType && prev.label === next.label && + prev.labelPrefix === next.labelPrefix && prev.meta === next.meta ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index d45a7fa8847..2ff8c4cdaeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -100,6 +100,56 @@ function renderSearchEntry( shortcut={entry.item.shortcut} /> ) + case 'blocks': + return ( + handlers.onSelectBlock(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + workflowType={entry.item.type} + label={entry.item.name} + /> + ) + case 'tools': + return ( + handlers.onSelectTool(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + /> + ) + case 'triggers': + return ( + handlers.onSelectTrigger(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + label={entry.item.name} + /> + ) + case 'toolOperations': + return ( + handlers.onSelectToolOperation(entry.item)} + icon={entry.item.icon} + bgColor={entry.item.bgColor} + showColoredIcon + labelPrefix={entry.item.serviceName} + label={entry.item.name} + /> + ) case 'connectedAccounts': return ( ({ +const { mockPush, mockSearchState } = vi.hoisted(() => ({ mockPush: vi.fn(), + mockSearchState: { + data: { + blocks: [] as unknown[], + tools: [] as unknown[], + triggers: [] as unknown[], + toolOperations: [] as unknown[], + docs: [] as unknown[], + isInitialized: true, + }, + }, })) vi.mock('next/navigation', () => ({ @@ -32,10 +42,30 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) +/** + * The real implementation runs the integration matcher over the block + * registry (globally mocked in jsdom); the palette only needs the store + * side effect, so delegate straight to LandingPromptStorage. + */ +vi.mock('@/blocks/integration-matcher', () => ({ + storeCuratedPrompt: (prompt: string) => LandingPromptStorage.store(prompt), +})) + vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ useInvokeGlobalCommand: () => vi.fn(), })) +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + hasTriggerCapability: () => false, +})) + +vi.mock('@/stores/modals/search/store', () => ({ + useSearchModalStore: Object.assign( + (selector: (state: typeof mockSearchState) => unknown) => selector(mockSearchState), + { getState: () => mockSearchState } + ), +})) + vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({ SIDEBAR_SCROLL_EVENT: 'sidebar-scroll-to-item', })) @@ -113,34 +143,111 @@ describe('SearchModal', () => { vi.unstubAllGlobals() }) - it('offers a new chat with the query when search has no results', async () => { + it('toggles ask mode with Tab and hands the query to Sim on Enter', async () => { const onOpenChange = vi.fn() await act(async () => { root.render() }) - await enterSearchQuery('explain quantum rainbows') + await enterSearchQuery('plan our launch week') + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) - const result = document.querySelector('[cmdk-item]') + const askRow = document.querySelector('[cmdk-item]') expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1) - expect(result?.textContent).toBe('New Chat: explain quantum rainbows') - expect(result?.querySelector('svg')).not.toBeNull() - expect(result?.getAttribute('aria-selected')).toBe('true') + expect(askRow?.textContent).toBe('Ask Sim: plan our launch week') + expect(askRow?.getAttribute('aria-selected')).toBe('true') act(() => { document - .querySelector('input[aria-label="Search anything"]') + .querySelector('input[aria-label="Ask Sim"]') ?.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) ) }) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home?handoff=1') - expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ - message: 'explain quantum rainbows', - contexts: undefined, + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') + expect(LandingPromptStorage.consume()).toBe('plan our launch week') + }) + + it('returns to search results when Tab is pressed again in ask mode', async () => { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('rainbow') + const input = document.querySelector('input[aria-label="Search anything"]') + act(() => { + input?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(document.querySelector('[cmdk-item]')?.textContent).toBe('Ask Sim: rainbow') + + act(() => { + document + .querySelector('input[aria-label="Ask Sim"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) }) + expect(document.querySelector('[cmdk-item]')?.textContent).toContain('Rainbow workflow') + }) + + it('puts the page itself first for an exact page-name query, then its contents', async () => { + const logs = [ + { + id: 'log-1', + name: 'Billing sync', + href: '/workspace/workspace-1/logs?executionId=e1', + date: 'Aug 8, 1:00 PM', + }, + { + id: 'log-2', + name: 'Onboarding', + href: '/workspace/workspace-1/logs?executionId=e2', + date: 'Aug 8, 2:00 PM', + }, + ] + await act(async () => { + root.render() + }) + + await enterSearchQuery('Logs') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Logs') + expect(rows[1]).toContain('Billing sync') + expect(rows[2]).toContain('Onboarding') + }) + + it('shows an empty state when search has no results', async () => { + await act(async () => { + root.render() + }) + + await enterSearchQuery('explain quantum rainbows') + + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0) + expect(document.querySelector('[cmdk-empty]')?.textContent).toBe('No results found.') }) it('sends the query directly when the new-chat surface is already mounted', async () => { @@ -157,6 +264,13 @@ describe('SearchModal', () => { root.render() }) await enterSearchQuery('summarize this workspace') + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) act(() => { document.querySelector('[cmdk-item]')?.click() @@ -164,61 +278,85 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() + expect(LandingPromptStorage.consume()).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } }) - it('returns selection to matching results after showing the new-chat fallback', async () => { - await act(async () => { - root.render( - - ) - }) - - await enterSearchQuery('nothing matches this') - expect(document.querySelector('[cmdk-item]')?.getAttribute('aria-selected')).toBe('true') - - await enterSearchQuery('rainbow') - const result = document.querySelector('[cmdk-item]') - expect(result?.textContent).toContain('Rainbow workflow') - expect(result?.getAttribute('aria-selected')).toBe('true') - }) - - it('orders canvas browse groups as Workflow Actions, Platform Actions, then the standard tail', async () => { + it('orders canvas browse groups as Actions, Sim, building blocks, then the standard tail', async () => { + const Icon = () => null + const block = { id: 'agent', name: 'Agent', icon: Icon, bgColor: '#111', type: 'agent' } + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + blocks: [block], + triggers: [{ ...block, id: 'schedule', name: 'Schedule', type: 'schedule' }], + tools: [{ ...block, id: 'slack', name: 'Slack', type: 'slack' }], + toolOperations: [ + { + id: 'slack_send_message', + name: 'Send Message', + serviceName: 'Slack', + searchValue: 'slack send-message', + icon: Icon, + bgColor: '#611f69', + blockType: 'slack', + operationId: 'send_message', + }, + ], + } const workflows = [ { id: 'workflow-a', name: 'Alpha workflow', href: '/workspace/workspace-1/w/workflow-a' }, ] - await act(async () => { - root.render( - - ) - }) + const integrations = [ + { + id: 'slack-int', + name: 'Slack', + href: '/integrations/slack', + icon: Icon, + bgColor: '#611f69', + }, + ] - const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( - (el) => el.textContent - ) - expect(headings.slice(0, 4)).toEqual(['Workflow Actions', 'Platform', 'Pages', 'Workflows']) + try { + await act(async () => { + root.render( + + ) + }) + + const headings = Array.from( + document.querySelectorAll('[cmdk-group-heading]') + ).map((el) => el.textContent) + expect(headings.slice(0, 8)).toEqual([ + 'Actions', + 'Sim', + 'Blocks', + 'Triggers', + 'Tools', + 'Tool operations', + 'Pages', + 'Workflows', + ]) + // Catalog and connected accounts stay off the canvas; the Integrations + // page row under Pages still navigates there. + expect(headings).not.toContain('Integrations') + expect(headings).not.toContain('Connected Integrations') + } finally { + mockSearchState.data = original + } }) - it('hoists a module page’s actions and entity section above Platform Actions', async () => { + it('hoists a module page’s actions and entity section above the Sim group', async () => { const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }] await act(async () => { root.render( @@ -229,7 +367,7 @@ describe('SearchModal', () => { const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( (el) => el.textContent ) - expect(headings.slice(0, 4)).toEqual(['Table Actions', 'Tables', 'Platform', 'Pages']) + expect(headings.slice(0, 4)).toEqual(['Actions', 'Tables', 'Sim', 'Pages']) }) it('browses the integrations catalog from every page', async () => { @@ -284,13 +422,20 @@ describe('SearchModal', () => { it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) try { await act(async () => { root.render() }) await enterSearchQuery('draft a launch plan') + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) act(() => { document.querySelector('[cmdk-item]')?.click() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 16a2b88f894..dc274936652 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1,6 +1,14 @@ 'use client' -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + type KeyboardEvent as ReactKeyboardEvent, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { cn, Library } from '@sim/emcn' import { Calendar, @@ -33,11 +41,10 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' -import { getMothershipHandoffHref } from '@/app/workspace/[workspaceId]/home/search-params' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { CommandFadedList, @@ -61,11 +68,12 @@ import type { WorkspaceItem, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { + CANVAS_SECTIONS, getActionGroupLabel, getGlobalSearchResults, - getPageActionGroupLabel, MAX_RESULTS_PER_GROUP, PAGE_CONTEXT_HOISTED_SECTION, + PAGE_MATCH_TIER, SEARCH_SECTIONS, SECTION_LABELS, scoreActions, @@ -76,8 +84,11 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useSearchModalStore } from '@/stores/modals/search/store' +import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') @@ -105,6 +116,7 @@ export function SearchModal({ const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string + const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) const listRef = useRef(null) const [mounted, setMounted] = useState(false) @@ -124,6 +136,8 @@ export function SearchModal({ setMounted(true) }, []) + const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data) + const openHelpModal = useCallback(() => { window.dispatchEvent(new CustomEvent('open-help-modal')) }, []) @@ -209,7 +223,6 @@ export function SearchModal({ */ const actions = useMemo((): ActionItem[] => { const list: ActionItem[] = [] - const onCanvas = pageContext === 'workflow' const invoke = (id: string) => () => invokeCommand(id) list.push( @@ -262,15 +275,13 @@ export function SearchModal({ run: () => routerRef.current.push(`/workspace/${workspaceId}/home`), }) } - /* On the canvas these three join the Workflow Actions group; everywhere - else they are platform verbs. */ if (canEdit && onCreateWorkflow) { list.push({ id: 'create-workflow', name: 'Create workflow', keywords: 'new add build', icon: Plus, - context: onCanvas ? 'workflow' : 'global', + context: 'global', run: onCreateWorkflow, }) } @@ -280,7 +291,7 @@ export function SearchModal({ name: 'Create folder', keywords: 'new add group', icon: FolderPlus, - context: onCanvas ? 'workflow' : 'global', + context: 'global', run: onCreateFolder, }) } @@ -290,7 +301,7 @@ export function SearchModal({ name: 'Import workflow', keywords: 'upload add', icon: Upload, - context: onCanvas ? 'workflow' : 'global', + context: 'global', run: onImportWorkflow, }) } @@ -551,10 +562,15 @@ export function SearchModal({ ]) const [search, setSearch] = useState('') + /** Tab-toggled ask mode: Enter hands the typed query to Sim as a new chat. */ + const [askMode, setAskMode] = useState(false) const [prevOpen, setPrevOpen] = useState(open) if (open !== prevOpen) { setPrevOpen(open) - if (open) setSearch('') + if (open) { + setSearch('') + setAskMode(false) + } } useEffect(() => { @@ -599,6 +615,20 @@ export function SearchModal({ }) }, []) + /** + * Tab flips between searching and asking Sim, keeping the typed text. On the + * way back the ask row unmounts while cmdk still remembers it as selected, + * so Home re-anchors the selection once the result rows are back. + */ + const handleSearchKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (event.key !== 'Tab' || !isChatEnabled) return + event.preventDefault() + setAskMode((mode) => !mode) + requestAnimationFrame(() => { + inputRef.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) + }) + }, []) + useEffect(() => { if (!open) return @@ -613,6 +643,57 @@ export function SearchModal({ return () => document.removeEventListener('keydown', handleKeyDown) }, [open]) + const handleBlockSelect = useCallback( + (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { + const enableTriggerMode = + type === 'trigger' && block.config ? hasTriggerCapability(block.config) : false + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: block.type, enableTriggerMode }, + }) + ) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: type, + query_length: deferredSearchRef.current.length, + workspace_id: workspaceId, + }) + onOpenChangeRef.current(false) + }, + [workspaceId] + ) + + const handleToolOperationSelect = useCallback( + (op: SearchToolOperationItem) => { + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { type: op.blockType, presetOperation: op.operationId }, + }) + ) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'tool_operation', + query_length: deferredSearchRef.current.length, + workspace_id: workspaceId, + }) + onOpenChangeRef.current(false) + }, + [workspaceId] + ) + + const handleBlockSelectAsBlock = useCallback( + (block: SearchBlockItem) => handleBlockSelect(block, 'block'), + [handleBlockSelect] + ) + + const handleBlockSelectAsTool = useCallback( + (tool: SearchBlockItem) => handleBlockSelect(tool, 'tool'), + [handleBlockSelect] + ) + + const handleBlockSelectAsTrigger = useCallback( + (trigger: SearchBlockItem) => handleBlockSelect(trigger, 'trigger'), + [handleBlockSelect] + ) + const handleWorkflowSelect = useCallback( (workflow: WorkflowItem) => { if (!workflow.isCurrent && workflow.href) { @@ -780,13 +861,15 @@ export function SearchModal({ const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + /* Same mechanism as the integrations "Explore" showcase: seed the chat + input on the freshly mounted home surface (integration names chip). */ + if (!storeCuratedPrompt(query)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) return } - routerRef.current.push(getMothershipHandoffHref(workspaceId)) + routerRef.current.push(homeHref) } onOpenChangeRef.current(false) @@ -798,6 +881,22 @@ export function SearchModal({ }) }, [workspaceId]) + /** Enter in ask mode: hand the query to Sim, or just open a new chat when empty. */ + const handleAskSim = useCallback(() => { + if (deferredSearchRef.current.trim()) { + handleNewChatFromQuery() + return + } + routerRef.current.push(`/workspace/${workspaceId}/home`) + onOpenChangeRef.current(false) + captureEvent(posthogRef.current, 'search_result_selected', { + result_type: 'action', + action_id: 'new-chat', + query_length: 0, + workspace_id: workspaceId, + }) + }, [workspaceId, handleNewChatFromQuery]) + const handleOverlayClick = useCallback(() => { onOpenChangeRef.current(false) }, []) @@ -820,7 +919,7 @@ export function SearchModal({ query ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) : items.map((item) => ({ item, score: 0 })) - const pageGroupLabel = pageContext ? getPageActionGroupLabel(pageContext) : null + const pageGroupLabel = pageContext ? ('Actions' as const) : null const rankedActions = [ ...(pageGroupLabel ? rankActionGroup( @@ -829,17 +928,57 @@ export function SearchModal({ ) : []), ...rankActionGroup( - availableActions.filter((action) => getActionGroupLabel(action) === 'Platform'), - 'Platform' + availableActions.filter((action) => getActionGroupLabel(action) === 'Sim'), + 'Sim' ), ] + const onCanvas = pageContext === 'workflow' + const availableBlocks = onCanvas + ? blocks.filter( + (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId + ) + : [] + const availableTools = onCanvas + ? tools.filter( + (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId + ) + : [] + return { actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), + blocks: rank( + 'blocks', + availableBlocks, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'blocks', item, score })), + triggers: rank( + 'triggers', + onCanvas + ? triggers.map((trigger) => ({ ...trigger, name: `${trigger.name} Trigger` })) + : [], + (item) => item.name, + (item) => `${toSearchToken(item.name)} ${item.id}` + ).map(({ item, score }) => ({ section: 'triggers', item, score })), + tools: rank( + 'tools', + availableTools, + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'tools', item, score })), + toolOperations: rank( + 'toolOperations', + onCanvas ? toolOperations : [], + (item) => item.name, + (item) => item.searchValue + ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), + /* An exact page-name query surfaces the page itself above its lifted + contents — typing "logs" opens with the Logs page, then the logs. */ pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ section: 'pages', item, - score, + score: item.name.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score, })), workflows: rank( 'workflows', @@ -875,10 +1014,15 @@ export function SearchModal({ item, score, })), - connectedAccounts: rank('connectedAccounts', connectedAccounts, (item) => item.name).map( - ({ item, score }) => ({ section: 'connectedAccounts', item, score }) - ), - integrations: rank('integrations', integrations, (item) => item.name).map( + /* On the canvas the blocks/tools sections already cover integration + intent — the catalog and connected accounts stay off; the Integrations + page row under Pages still navigates there. */ + connectedAccounts: rank( + 'connectedAccounts', + onCanvas ? [] : connectedAccounts, + (item) => item.name + ).map(({ item, score }) => ({ section: 'connectedAccounts', item, score })), + integrations: rank('integrations', onCanvas ? [] : integrations, (item) => item.name).map( ({ item, score }) => ({ section: 'integrations', item, score }) ), chats: rank('chats', chats, (item) => item.name).map(({ item, score }) => ({ @@ -891,6 +1035,11 @@ export function SearchModal({ deferredSearch, actions, pageContext, + blocks, + tools, + triggers, + toolOperations, + currentWorkflowId, integrations, connectedAccounts, chats, @@ -923,8 +1072,7 @@ export function SearchModal({ () => (isSearching ? getGlobalSearchResults(entriesBySection, orderedSections) : []), [orderedSections, entriesBySection, isSearching] ) - const showNewChatFallback = isSearching && searchResults.length === 0 && isChatEnabled - const newChatFallbackLabel = `New Chat: ${searchQuery}` + const askSimLabel = searchQuery ? `Ask Sim: ${searchQuery}` : 'Start a new chat' const sectionGroups = useMemo(() => { const actionEntriesByLabel = (label: ActionGroupLabel) => entriesBySection.actions.filter( @@ -935,9 +1083,10 @@ export function SearchModal({ heading: SECTION_LABELS[section], entries: entriesBySection[section], }) - const pageGroupLabel = pageContext ? getPageActionGroupLabel(pageContext) : null + const pageGroupLabel = pageContext ? ('Actions' as const) : null const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined + const canvasSections = new Set(CANVAS_SECTIONS) return [ ...(pageGroupLabel ? [ @@ -951,18 +1100,23 @@ export function SearchModal({ ...(hoisted ? [entityGroup(hoisted)] : []), { key: 'platform-actions', - heading: 'Platform', - entries: actionEntriesByLabel('Platform'), + heading: 'Sim', + entries: actionEntriesByLabel('Sim'), }, - ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoisted).map( - entityGroup - ), + ...CANVAS_SECTIONS.map(entityGroup), + ...SEARCH_SECTIONS.filter( + (section) => section !== 'actions' && section !== hoisted && !canvasSections.has(section) + ).map(entityGroup), ] }, [entriesBySection, pageContext]) const entryHandlers = useMemo( (): SearchEntryHandlers => ({ onSelectAction: handleActionSelect, + onSelectBlock: handleBlockSelectAsBlock, + onSelectTool: handleBlockSelectAsTool, + onSelectTrigger: handleBlockSelectAsTrigger, + onSelectToolOperation: handleToolOperationSelect, onSelectConnectedAccount: handleConnectedAccountSelect, onSelectIntegration: handleIntegrationSelect, onSelectChat: handleChatSelect, @@ -976,6 +1130,10 @@ export function SearchModal({ }), [ handleActionSelect, + handleBlockSelectAsBlock, + handleBlockSelectAsTool, + handleBlockSelectAsTrigger, + handleToolOperationSelect, handleConnectedAccountSelect, handleIntegrationSelect, handleChatSelect, @@ -1022,7 +1180,7 @@ export function SearchModal({ label='Search' shouldFilter={false} loop - value={showNewChatFallback ? newChatFallbackLabel : undefined} + value={askMode ? askSimLabel : undefined} >
- {showNewChatFallback ? ( + {askMode ? ( ) : isSearching ? ( + {askMode ? '⇥ Search' : '⇥ Ask Sim'} + + ) : undefined + } />
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 6f9f527b700..ea432f24697 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -8,7 +8,6 @@ import { fuzzyMatch, getActionGroupLabel, getGlobalSearchResults, - getPageActionGroupLabel, MAX_RESULTS_PER_GROUP, type SearchEntry, scoreActions, @@ -24,30 +23,11 @@ describe('getActionGroupLabel', () => { run: () => {}, } - it('separates page actions from platform actions', () => { - expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Workflow Actions') - expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Platform') - }) - - it('groups page actions under their module name', () => { - expect(getActionGroupLabel({ ...action, context: 'tables' })).toBe('Table Actions') - expect(getActionGroupLabel({ ...action, context: 'tableDetail' })).toBe('Table Actions') - expect(getActionGroupLabel({ ...action, context: 'files' })).toBe('File Actions') - expect(getActionGroupLabel({ ...action, context: 'fileDetail' })).toBe('File Actions') - expect(getActionGroupLabel({ ...action, context: 'knowledge' })).toBe('Knowledge Base Actions') - expect(getActionGroupLabel({ ...action, context: 'knowledgeBase' })).toBe( - 'Knowledge Base Actions' - ) - expect(getActionGroupLabel({ ...action, context: 'logs' })).toBe('Logs Actions') - expect(getActionGroupLabel({ ...action, context: 'logsDashboard' })).toBe('Logs Actions') - expect(getActionGroupLabel({ ...action, context: 'scheduledTasks' })).toBe( - 'Scheduled Task Actions' - ) - }) - - it('resolves the same module labels for page contexts directly', () => { - expect(getPageActionGroupLabel('tables')).toBe('Table Actions') - expect(getPageActionGroupLabel('knowledgeBase')).toBe('Knowledge Base Actions') + it('separates page actions from Sim actions', () => { + expect(getActionGroupLabel({ ...action, context: 'workflow' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'tables' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'logsDashboard' })).toBe('Actions') + expect(getActionGroupLabel({ ...action, context: 'global' })).toBe('Sim') }) it('lets an action group label surface actions whose names do not match', () => { @@ -57,10 +37,8 @@ describe('getActionGroupLabel', () => { context: 'workflow' as const, } - expect(scoreActions([workflowAction], 'workflow actions', 50, 'Workflow Actions')).toHaveLength( - 1 - ) - expect(scoreActions([workflowAction], 'platform', 50, 'Workflow Actions')).toHaveLength(0) + expect(scoreActions([workflowAction], 'actions', 50, 'Actions')).toHaveLength(1) + expect(scoreActions([workflowAction], 'platform', 50, 'Actions')).toHaveLength(0) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index df1eb8a5fac..fb6da959f21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -1,5 +1,6 @@ import type { ComponentType } from 'react' import { toSearchToken } from '@/lib/search/tokens' +import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' /** * Every result group the palette can render. This is also the canonical order: @@ -9,6 +10,10 @@ import { toSearchToken } from '@/lib/search/tokens' */ export const SEARCH_SECTIONS = [ 'actions', + 'blocks', + 'triggers', + 'tools', + 'toolOperations', 'pages', 'workflows', 'workspaces', @@ -24,6 +29,12 @@ export const SEARCH_SECTIONS = [ /** A single search-modal result group. */ export type SearchSection = (typeof SEARCH_SECTIONS)[number] +/** + * Canvas building-block sections. They render between the page's action group + * and the Sim group; off the canvas they carry no items and render nothing. + */ +export const CANVAS_SECTIONS = ['blocks', 'triggers', 'tools', 'toolOperations'] as const + export interface IntegrationSearchItem { id: string name: string @@ -122,27 +133,7 @@ export interface ActionItem { run: () => void } -export type ActionGroupLabel = - | 'Platform' - | 'Workflow Actions' - | 'Table Actions' - | 'File Actions' - | 'Knowledge Base Actions' - | 'Logs Actions' - | 'Scheduled Task Actions' - -const PAGE_CONTEXT_GROUP_LABELS: Record = { - workflow: 'Workflow Actions', - tables: 'Table Actions', - tableDetail: 'Table Actions', - files: 'File Actions', - fileDetail: 'File Actions', - knowledge: 'Knowledge Base Actions', - knowledgeBase: 'Knowledge Base Actions', - logs: 'Logs Actions', - logsDashboard: 'Logs Actions', - scheduledTasks: 'Scheduled Task Actions', -} +export type ActionGroupLabel = 'Sim' | 'Actions' /** * The page's own entity section, hoisted directly under its action group in @@ -159,14 +150,9 @@ export const PAGE_CONTEXT_HOISTED_SECTION: Partial = { - actions: 'Platform', + actions: 'Sim', + blocks: 'Blocks', + triggers: 'Triggers', + tools: 'Tools', + toolOperations: 'Tool operations', pages: 'Pages', workflows: 'Workflows', workspaces: 'Workspaces', @@ -224,6 +216,8 @@ export const SECTION_LABELS: Record = { export type SearchEntry = | { section: 'actions'; score: number; item: ActionItem } + | { section: 'blocks' | 'tools' | 'triggers'; score: number; item: SearchBlockItem } + | { section: 'toolOperations'; score: number; item: SearchToolOperationItem } | { section: 'connectedAccounts' | 'integrations'; score: number; item: IntegrationSearchItem } | { section: 'chats'; score: number; item: TaskItem } | { section: 'workflows'; score: number; item: WorkflowItem } @@ -235,6 +229,10 @@ export type SearchEntry = export interface SearchEntryHandlers { onSelectAction: (item: ActionItem) => void + onSelectBlock: (item: SearchBlockItem) => void + onSelectTool: (item: SearchBlockItem) => void + onSelectTrigger: (item: SearchBlockItem) => void + onSelectToolOperation: (item: SearchToolOperationItem) => void onSelectConnectedAccount: (item: IntegrationSearchItem) => void onSelectIntegration: (item: IntegrationSearchItem) => void onSelectChat: (item: TaskItem) => void @@ -451,6 +449,13 @@ const NAME_MATCH_TIER = 1_000_000 */ const SECTION_MATCH_TIER = 2_000_000 +/** + * Rank offset for a page row whose name IS the query. Typing "logs" means the + * Logs page itself first, then its contents (the section lifted into + * {@link SECTION_MATCH_TIER}) beneath it. + */ +export const PAGE_MATCH_TIER = 3_000_000 + /** * Ranks an item by its name first, falling back to secondary text (ids, aliases, * option labels) only when the name doesn't match — a name match always wins, so @@ -568,7 +573,7 @@ export function scoreActions( actions: ActionItem[], search: string, maxResults = Number.POSITIVE_INFINITY, - groupLabel: ActionGroupLabel = 'Platform' + groupLabel: ActionGroupLabel = 'Sim' ): Array<{ item: ActionItem; score: number }> { return scoreItemsForSection( groupLabel, diff --git a/apps/sim/blocks/blocks/generic_webhook.ts b/apps/sim/blocks/blocks/generic_webhook.ts index 7cc559f4f3c..8ab207a1f36 100644 --- a/apps/sim/blocks/blocks/generic_webhook.ts +++ b/apps/sim/blocks/blocks/generic_webhook.ts @@ -4,7 +4,7 @@ import { getTrigger } from '@/triggers' export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', - name: 'Webhook Trigger', + name: 'Webhook', description: 'Receive webhooks from any service by configuring a custom webhook.', category: 'triggers', icon: Webhook, diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 6d6f4a2cfe2..27be0298ee9 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -565,6 +565,7 @@ export interface PostHogEventMap { result_type: | 'block' | 'tool' + | 'trigger' | 'tool_operation' | 'connected_account' | 'integration' diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 1086e815c37..5cc1a9fdc11 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -140,7 +140,7 @@ export const useSearchModalStore = create()( const allTriggers = getTriggersForSidebar() const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers - const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] + const priorityOrder = ['Start', 'Schedule', 'Webhook'] const sortedTriggers = [...filteredTriggers].sort( (a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => { From 00dceb410ad24a147982bcdaed2ebec287f0b479 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:28:29 -0700 Subject: [PATCH 13/29] fix(search): order module results below Sim actions --- .../sidebar/components/search-modal/search-modal.test.tsx | 4 ++-- .../sidebar/components/search-modal/search-modal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index da374f1a173..e47f102e573 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -356,7 +356,7 @@ describe('SearchModal', () => { } }) - it('hoists a module page’s actions and entity section above the Sim group', async () => { + it('hoists a module page’s actions and its entity section directly under the Sim group', async () => { const tables = [{ id: 'table-1', name: 'Leads', href: '/workspace/workspace-1/tables/table-1' }] await act(async () => { root.render( @@ -367,7 +367,7 @@ describe('SearchModal', () => { const headings = Array.from(document.querySelectorAll('[cmdk-group-heading]')).map( (el) => el.textContent ) - expect(headings.slice(0, 4)).toEqual(['Actions', 'Tables', 'Sim', 'Pages']) + expect(headings.slice(0, 4)).toEqual(['Actions', 'Sim', 'Tables', 'Pages']) }) it('browses the integrations catalog from every page', async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index dc274936652..afe94df1817 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1097,12 +1097,12 @@ export function SearchModal({ }, ] : []), - ...(hoisted ? [entityGroup(hoisted)] : []), { key: 'platform-actions', heading: 'Sim', entries: actionEntriesByLabel('Sim'), }, + ...(hoisted ? [entityGroup(hoisted)] : []), ...CANVAS_SECTIONS.map(entityGroup), ...SEARCH_SECTIONS.filter( (section) => section !== 'actions' && section !== hoisted && !canvasSections.has(section) From ecb6adec01726de9198687a85e71cc170f830ac5 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:03:01 -0700 Subject: [PATCH 14/29] fix(review): make command palette fast and focused --- .../hooks/use-mothership-handoff.test.tsx | 6 +- .../components/log-details/log-details.tsx | 3 +- .../[workspaceId]/tables/[tableId]/table.tsx | 28 +- .../panel/components/toolbar/toolbar.tsx | 4 +- .../search-modal/search-modal.test.tsx | 140 ++++- .../components/search-modal/search-modal.tsx | 197 +++---- .../components/search-modal/utils.test.ts | 3 - .../sidebar/components/search-modal/utils.ts | 2 +- .../w/components/sidebar/sidebar.tsx | 6 +- apps/sim/blocks/blocks/generic_webhook.ts | 2 +- apps/sim/stores/modals/search/store.ts | 2 +- ...ontext-aware-command-palette-ideation.html | 536 ------------------ 12 files changed, 240 insertions(+), 689 deletions(-) delete mode 100644 docs/ideation/2026-08-04-context-aware-command-palette-ideation.html diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx index 36124776a86..d82b5e6dac9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx @@ -20,7 +20,11 @@ vi.mock('nuqs', () => ({ const mockSendMessage = vi.fn(async () => {}) -function TestHarness({ renderKey }: { renderKey: number }) { +interface TestHarnessProps { + renderKey: number +} + +function TestHarness({ renderKey }: TestHarnessProps) { useMothershipHandoff({ workspaceId: 'workspace-1', sendMessage: mockSendMessage, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 7a8c9506a5e..804641c643e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -47,6 +47,7 @@ import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-spans' import type { TraceSpan } from '@/lib/logs/types' import { sendMothershipMessage } from '@/lib/mothership/events' +import { getMothershipHandoffHref } from '@/app/workspace/[workspaceId]/home/search-params' import { ExecutionSnapshot, FileCards, @@ -446,7 +447,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP : 'This workflow run failed. Investigate the error in this run and help me fix it.' if (sendMothershipMessage(message, [context])) return if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) { - router.push(`/workspace/${workspaceId}/home`) + router.push(getMothershipHandoffHref(workspaceId)) } }, [log.executionId, log.workflow?.name, workspaceId, router]) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index d1f0015b6bd..e97eb48963b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -1070,9 +1070,31 @@ export function Table({ }, [tableData, workspaceId]) useRegisterGlobalCommands(() => [ - { id: 'table-new-column', handler: () => handleAddColumnOfType('string') }, - { id: 'table-export-csv', handler: () => void handleExportCsv() }, - { id: 'table-import-csv', handler: () => setIsImportCsvOpen(true) }, + { + id: 'table-new-column', + handler: () => { + if (!userPermissions.canEdit) return + if (tableDataRef.current?.locks.schemaLocked) { + showBlockedToast('add-column') + return + } + handleAddColumnOfType('string') + }, + }, + { + id: 'table-export-csv', + handler: () => { + if (!tableDataRef.current?.rowCount) return + void handleExportCsv() + }, + }, + { + id: 'table-import-csv', + handler: () => { + if (!userPermissions.canEdit || tableDataRef.current?.locks.insertLocked) return + onRequestImportCsv() + }, + }, ]) const columnOptions = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index a436a6ddfbb..5cdfdda7f5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -183,14 +183,14 @@ function syncCachesToOverlayVersion(version: number) { /** * Gets triggers data, computing it once per overlay version and caching for - * subsequent calls. Non-integration triggers (Start, Schedule, Webhook) are + * subsequent calls. Non-integration triggers (Start, Schedule, Webhook Trigger) are * prioritized first, followed by all other triggers sorted alphabetically. */ function getTriggers(overlayVersion: number): BlockItem[] { syncCachesToOverlayVersion(overlayVersion) if (cachedTriggers === null) { const allTriggers = getTriggersForSidebar() - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = allTriggers.sort((a, b) => { const aIndex = priorityOrder.indexOf(a.name) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index e47f102e573..ec8dafa4bda 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -42,15 +42,6 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) -/** - * The real implementation runs the integration matcher over the block - * registry (globally mocked in jsdom); the palette only needs the store - * side effect, so delegate straight to LandingPromptStorage. - */ -vi.mock('@/blocks/integration-matcher', () => ({ - storeCuratedPrompt: (prompt: string) => LandingPromptStorage.store(prompt), -})) - vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ useInvokeGlobalCommand: () => vi.fn(), })) @@ -149,7 +140,7 @@ describe('SearchModal', () => { root.render() }) - await enterSearchQuery('plan our launch week') + await enterSearchQuery('plan our Slack launch week') const input = document.querySelector('input[aria-label="Search anything"]') act(() => { input?.dispatchEvent( @@ -159,7 +150,7 @@ describe('SearchModal', () => { const askRow = document.querySelector('[cmdk-item]') expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1) - expect(askRow?.textContent).toBe('Ask Sim: plan our launch week') + expect(askRow?.textContent).toBe('Ask Sim: plan our Slack launch week') expect(askRow?.getAttribute('aria-selected')).toBe('true') act(() => { @@ -171,8 +162,11 @@ describe('SearchModal', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') - expect(LandingPromptStorage.consume()).toBe('plan our launch week') + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home?handoff=1') + expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ + message: 'plan our Slack launch week', + contexts: undefined, + }) }) it('returns to search results when Tab is pressed again in ask mode', async () => { @@ -278,7 +272,7 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(LandingPromptStorage.consume()).toBeNull() + expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } @@ -325,7 +319,6 @@ describe('SearchModal', () => { { 'Pages', 'Workflows', ]) - // Catalog and connected accounts stay off the canvas; the Integrations - // page row under Pages still navigates there. expect(headings).not.toContain('Integrations') expect(headings).not.toContain('Connected Integrations') } finally { @@ -420,9 +411,120 @@ describe('SearchModal', () => { expect(rows()[1]?.getAttribute('aria-selected')).toBe('false') }) + it('unmounts while closed and reopens with a blank query', async () => { + await act(async () => { + root.render() + }) + await enterSearchQuery('previous search') + + act(() => { + document + .querySelector('input[aria-label="Search anything"]') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) + ) + }) + expect(document.querySelector('input[aria-label="Ask Sim"]')).not.toBeNull() + + await act(async () => { + root.render() + }) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(0) + + await act(async () => { + root.render() + }) + const input = document.querySelector('input[aria-label="Search anything"]') + expect(input?.value).toBe('') + expect(document.querySelector('input[aria-label="Ask Sim"]')).toBeNull() + }) + + it('bounds browse rows while keeping later tool operations searchable', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + toolOperations: Array.from({ length: 75 }, (_, index) => ({ + id: `service_operation_${index}`, + name: `Operation ${index}`, + serviceName: 'Service', + searchValue: `service operation-${index}`, + icon: Icon, + bgColor: '#111', + blockType: 'service', + operationId: `operation_${index}`, + })), + } + + try { + await act(async () => { + root.render() + }) + + const browseRows = Array.from(document.querySelectorAll('[cmdk-item]')).filter( + (row) => row.textContent?.includes('Operation') + ) + expect(browseRows).toHaveLength(8) + + await enterSearchQuery('Operation') + expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(50) + + await enterSearchQuery('Operation 74') + const exactRows = Array.from(document.querySelectorAll('[cmdk-item]')) + expect(exactRows.some((row) => row.textContent?.includes('Operation 74'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('does not offer deploy to workflow users without admin access', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(document.body.textContent).not.toContain('Deploy workflow') + + await act(async () => { + root.render( + + ) + }) + expect(document.body.textContent).toContain('Deploy workflow') + }) + + it('does not duplicate the Trigger suffix', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + triggers: [ + { + id: 'generic_webhook', + name: 'Webhook Trigger', + icon: Icon, + bgColor: '#111', + type: 'generic_webhook', + }, + ], + } + + try { + await act(async () => { + root.render() + }) + expect(document.body.textContent).toContain('Webhook Trigger') + expect(document.body.textContent).not.toContain('Webhook Trigger Trigger') + } finally { + mockSearchState.data = original + } + }) + it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) try { await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index afe94df1817..76c5e1513f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -3,7 +3,6 @@ import { type KeyboardEvent as ReactKeyboardEvent, useCallback, - useDeferredValue, useEffect, useMemo, useRef, @@ -41,10 +40,12 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { isChatEnabled } from '@/lib/core/config/env-flags' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { getMothershipHandoffHref } from '@/app/workspace/[workspaceId]/home/search-params' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { CommandFadedList, @@ -84,18 +85,31 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') +const MAX_BROWSE_RESULTS_PER_GROUP = 8 +const MAX_SEARCH_RESULTS = 50 export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -export function SearchModal({ - open, +interface SearchModalContentProps extends Omit {} + +export function SearchModal({ open, ...props }: SearchModalProps) { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted || !open) return null + return +} + +function SearchModalContent({ onOpenChange, workflows = [], workspaces = [], @@ -106,20 +120,19 @@ export function SearchModal({ logs = [], integrations = [], connectedAccounts = [], - isOnWorkflowPage = false, pageContext = null, canEdit = false, + canAdmin = false, onCreateWorkflow, onCreateFolder, onImportWorkflow, -}: SearchModalProps) { +}: SearchModalContentProps) { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) const listRef = useRef(null) - const [mounted, setMounted] = useState(false) const { navigateToSettings } = useSettingsNavigation() const { config: permissionConfig } = usePermissionConfig() const invokeCommand = useInvokeGlobalCommand() @@ -132,10 +145,6 @@ export function SearchModal({ const posthogRef = useRef(posthog) posthogRef.current = posthog - useEffect(() => { - setMounted(true) - }, []) - const { blocks, tools, triggers, toolOperations } = useSearchModalStore((state) => state.data) const openHelpModal = useCallback(() => { @@ -225,24 +234,26 @@ export function SearchModal({ const list: ActionItem[] = [] const invoke = (id: string) => () => invokeCommand(id) - list.push( - { - id: 'run-workflow', - name: 'Run workflow', - keywords: 'execute start play test', - icon: Play, - shortcut: '⌘↵', - context: 'workflow', - run: invoke('run-workflow'), - }, - { + list.push({ + id: 'run-workflow', + name: 'Run workflow', + keywords: 'execute start play test', + icon: Play, + shortcut: '⌘↵', + context: 'workflow', + run: invoke('run-workflow'), + }) + if (canAdmin) { + list.push({ id: 'deploy-workflow', name: 'Deploy workflow', keywords: 'ship release publish api', icon: Rocket, context: 'workflow', run: invoke('deploy-workflow'), - }, + }) + } + list.push( { id: 'fit-to-view', name: 'Fit workflow to view', @@ -553,6 +564,7 @@ export function SearchModal({ }, [ workspaceId, canEdit, + canAdmin, pageContext, onCreateWorkflow, onCreateFolder, @@ -564,51 +576,11 @@ export function SearchModal({ const [search, setSearch] = useState('') /** Tab-toggled ask mode: Enter hands the typed query to Sim as a new chat. */ const [askMode, setAskMode] = useState(false) - const [prevOpen, setPrevOpen] = useState(open) - if (open !== prevOpen) { - setPrevOpen(open) - if (open) { - setSearch('') - setAskMode(false) - } - } - - useEffect(() => { - if (!open || !inputRef.current) return - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - if (nativeInputValueSetter) { - nativeInputValueSetter.call(inputRef.current, '') - inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) - } - inputRef.current.focus() - /** - * cmdk keeps its last selected value across closes and does not re-anchor - * when items mount above it (it only auto-selects when nothing is selected - * yet), so a palette whose top rows appeared after mount — e.g. page - * actions gated on async permissions — would open with a mid-list row - * selected. Home re-selects the first row on every open. - */ - inputRef.current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })) - /** - * After the frame settles, pin the list back to the very top. cmdk's own - * `scrollIntoView({ block: 'nearest' })` stops as soon as the selected row - * edges into the scrollport, which parks it under the floating search - * input; and without any reset a reopened palette keeps its previous - * scroll offset. - */ - requestAnimationFrame(() => { - if (listRef.current) listRef.current.scrollTop = 0 - }) - }, [open]) - - const deferredSearch = useDeferredValue(search) - const deferredSearchRef = useRef(deferredSearch) - deferredSearchRef.current = deferredSearch + const searchRef = useRef(search) + searchRef.current = search const handleSearchChange = useCallback((value: string) => { + searchRef.current = value setSearch(value) requestAnimationFrame(() => { if (listRef.current) listRef.current.scrollTop = 0 @@ -630,8 +602,6 @@ export function SearchModal({ }, []) useEffect(() => { - if (!open) return - const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() @@ -641,7 +611,7 @@ export function SearchModal({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [open]) + }, []) const handleBlockSelect = useCallback( (block: SearchBlockItem, type: 'block' | 'trigger' | 'tool') => { @@ -654,7 +624,7 @@ export function SearchModal({ ) captureEvent(posthogRef.current, 'search_result_selected', { result_type: type, - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -671,7 +641,7 @@ export function SearchModal({ ) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'tool_operation', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -704,7 +674,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'workflow', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -719,7 +689,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'workspace', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -732,7 +702,7 @@ export function SearchModal({ routerRef.current.push(chat.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'task', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -745,7 +715,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'table', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -758,7 +728,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'file', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -771,7 +741,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'knowledge_base', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -792,7 +762,7 @@ export function SearchModal({ } captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'page', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -805,7 +775,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'log', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -818,7 +788,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'connected_account', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -831,7 +801,7 @@ export function SearchModal({ routerRef.current.push(item.href) captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'integration', - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) onOpenChangeRef.current(false) @@ -846,7 +816,7 @@ export function SearchModal({ captureEvent(posthogRef.current, 'search_result_selected', { result_type: 'action', action_id: item.id, - query_length: deferredSearchRef.current.length, + query_length: searchRef.current.length, workspace_id: workspaceId, }) }, @@ -854,22 +824,20 @@ export function SearchModal({ ) const handleNewChatFromQuery = useCallback(() => { - const query = deferredSearchRef.current.trim() + const query = searchRef.current.trim() if (!query) return const homeHref = `/workspace/${workspaceId}/home` const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - /* Same mechanism as the integrations "Explore" showcase: seed the chat - input on the freshly mounted home surface (integration names chip). */ - if (!storeCuratedPrompt(query)) { + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) return } - routerRef.current.push(homeHref) + routerRef.current.push(getMothershipHandoffHref(workspaceId)) } onOpenChangeRef.current(false) @@ -883,7 +851,7 @@ export function SearchModal({ /** Enter in ask mode: hand the query to Sim, or just open a new chat when empty. */ const handleAskSim = useCallback(() => { - if (deferredSearchRef.current.trim()) { + if (searchRef.current.trim()) { handleNewChatFromQuery() return } @@ -902,7 +870,7 @@ export function SearchModal({ }, []) const entriesBySection = useMemo((): Record => { - const query = deferredSearch.trim() + const query = search.trim() const rank = ( section: SearchSection, items: T[], @@ -910,15 +878,15 @@ export function SearchModal({ toExtra?: (item: T) => string | undefined ) => query - ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra, MAX_RESULTS_PER_GROUP) - : items.map((item) => ({ item, score: 0 })) + ? scoreSectionItems(section, items, toValue, search, toExtra, MAX_RESULTS_PER_GROUP) + : items.slice(0, MAX_BROWSE_RESULTS_PER_GROUP).map((item) => ({ item, score: 0 })) const availableActions = actions.filter( (action) => action.context === 'global' || action.context === pageContext ) const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => query - ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) - : items.map((item) => ({ item, score: 0 })) + ? scoreActions(items, search, MAX_RESULTS_PER_GROUP, groupLabel) + : items.slice(0, MAX_BROWSE_RESULTS_PER_GROUP).map((item) => ({ item, score: 0 })) const pageGroupLabel = pageContext ? ('Actions' as const) : null const rankedActions = [ ...(pageGroupLabel @@ -956,7 +924,10 @@ export function SearchModal({ triggers: rank( 'triggers', onCanvas - ? triggers.map((trigger) => ({ ...trigger, name: `${trigger.name} Trigger` })) + ? triggers.map((trigger) => ({ + ...trigger, + name: trigger.name.endsWith('Trigger') ? trigger.name : `${trigger.name} Trigger`, + })) : [], (item) => item.name, (item) => `${toSearchToken(item.name)} ${item.id}` @@ -973,8 +944,6 @@ export function SearchModal({ (item) => item.name, (item) => item.searchValue ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), - /* An exact page-name query surfaces the page itself above its lifted - contents — typing "logs" opens with the Logs page, then the logs. */ pages: rank('pages', pages, (item) => item.name).map(({ item, score }) => ({ section: 'pages', item, @@ -1014,9 +983,6 @@ export function SearchModal({ item, score, })), - /* On the canvas the blocks/tools sections already cover integration - intent — the catalog and connected accounts stay off; the Integrations - page row under Pages still navigates there. */ connectedAccounts: rank( 'connectedAccounts', onCanvas ? [] : connectedAccounts, @@ -1032,7 +998,7 @@ export function SearchModal({ })), } }, [ - deferredSearch, + search, actions, pageContext, blocks, @@ -1052,7 +1018,7 @@ export function SearchModal({ pages, ]) - const searchQuery = deferredSearch.trim() + const searchQuery = search.trim() const isSearching = Boolean(searchQuery) /** * Section order for both the browse list and the flat search tie-break: the @@ -1069,7 +1035,10 @@ export function SearchModal({ ] }, [pageContext]) const searchResults = useMemo( - () => (isSearching ? getGlobalSearchResults(entriesBySection, orderedSections) : []), + () => + isSearching + ? getGlobalSearchResults(entriesBySection, orderedSections).slice(0, MAX_SEARCH_RESULTS) + : [], [orderedSections, entriesBySection, isSearching] ) const askSimLabel = searchQuery ? `Ask Sim: ${searchQuery}` : 'Start a new chat' @@ -1147,32 +1116,23 @@ export function SearchModal({ ] ) - if (!mounted) return null - return createPortal( <>
@@ -1227,6 +1187,7 @@ export function SearchModal({ cycleResultsOnTab={!isChatEnabled} autoFocus aria-label={askMode ? 'Ask Sim' : 'Search anything'} + value={search} onValueChange={handleSearchChange} onKeyDown={handleSearchKeyDown} placeholder={askMode ? 'Ask Sim anything...' : 'Search anything...'} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index ea432f24697..20e4cbcd0c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -479,8 +479,6 @@ describe('secondary-text matching — no scattered noise', () => { it('does not scatter-match a query across long unrelated secondary text', () => { const items = [{ name: 'Write Contact', extra: 'Wealthbox Write Contact match snap up' }] - // The scattered mode alone finds w…h…a…t…s…a…p…p across this extra, so - // without the restriction this item would surface for "whatsapp". expect(fuzzyMatch(items[0].extra, 'whatsapp').matched).toBe(true) expect( filterAndSort( @@ -532,7 +530,6 @@ describe('secondary-text matching — no scattered noise', () => { (item) => item.extra ) ).toHaveLength(1) - // "dmchat" needs letters from both the "dm" and "chat" entries — rejected. expect( filterAndSort( items, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index fb6da959f21..e3c95dcb265 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -167,10 +167,10 @@ export interface SearchModalProps { logs?: LogItem[] integrations?: IntegrationSearchItem[] connectedAccounts?: IntegrationSearchItem[] - isOnWorkflowPage?: boolean /** Page the palette was opened on, when that page contributes actions. */ pageContext?: PageActionContext | null canEdit?: boolean + canAdmin?: boolean onCreateWorkflow?: () => void onCreateFolder?: () => void onImportWorkflow?: () => void diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index ae9f0e5d3b4..a973415d9a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -428,7 +428,7 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() - const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() const { config: permissionConfig, filterBlocks } = usePermissionConfig() const { navigateToSettings, getSettingsHref } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) @@ -1143,7 +1143,7 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedCredentials = [] } = useWorkspaceCredentials({ workspaceId, - enabled: !permissionConfig.hideIntegrationsTab, + enabled: !permissionConfig.hideIntegrationsTab && searchModalPageContext !== 'workflow', }) const isOnLogsPage = @@ -1926,9 +1926,9 @@ export const Sidebar = memo(function Sidebar({ logs={searchModalLogs} integrations={searchModalIntegrations} connectedAccounts={searchModalConnectedAccounts} - isOnWorkflowPage={!!workflowId} pageContext={searchModalPageContext} canEdit={canEdit} + canAdmin={canAdmin} onCreateWorkflow={handleCreateWorkflow} onCreateFolder={handleCreateFolder} onImportWorkflow={handleImportWorkflow} diff --git a/apps/sim/blocks/blocks/generic_webhook.ts b/apps/sim/blocks/blocks/generic_webhook.ts index 8ab207a1f36..7cc559f4f3c 100644 --- a/apps/sim/blocks/blocks/generic_webhook.ts +++ b/apps/sim/blocks/blocks/generic_webhook.ts @@ -4,7 +4,7 @@ import { getTrigger } from '@/triggers' export const GenericWebhookBlock: BlockConfig = { type: 'generic_webhook', - name: 'Webhook', + name: 'Webhook Trigger', description: 'Receive webhooks from any service by configuring a custom webhook.', category: 'triggers', icon: Webhook, diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 5cc1a9fdc11..1086e815c37 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -140,7 +140,7 @@ export const useSearchModalStore = create()( const allTriggers = getTriggersForSidebar() const filteredTriggers = filterBlocks(allTriggers) as typeof allTriggers - const priorityOrder = ['Start', 'Schedule', 'Webhook'] + const priorityOrder = ['Start', 'Schedule', 'Webhook Trigger'] const sortedTriggers = [...filteredTriggers].sort( (a: (typeof filteredTriggers)[number], b: (typeof filteredTriggers)[number]) => { diff --git a/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html b/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html deleted file mode 100644 index 889f5b64530..00000000000 --- a/docs/ideation/2026-08-04-context-aware-command-palette-ideation.html +++ /dev/null @@ -1,536 +0,0 @@ - - - - - - Ideation: Context-Aware Command Palette - - - -
-
-

Repo-grounded ideation

-

Context-aware command palette

-

Cmd+K can become the fastest way to continue work on the current page—not merely a global directory. The strongest direction layers page-native actions, scoped entities, and result-owned operations without bloating the palette.

- - - -
-
25consolidated raw candidates
-
6ranked directions
-
5topic axes covered
-
-
- -
-

Grounding Context

-

Codebase Context

-

The opportunity is not hypothetical: most of the useful verbs, state, queries, and permission checks already exist on their owning pages. Cmd+K currently lacks the contribution seam that would project them consistently.

- -
-
- Context exists, but it is coarse -

The palette distinguishes global, workflow, and integrations. Canvas actions prove the page-aware model; KBs, Tables, Files, Logs, and Scheduled Tasks have not yet joined it.

-
-
- Deep entities are already queryable -

KB documents/connectors and table rows/views already have scoped server queries. Executions, schedules, credentials, files, and folders already carry stable identity and useful metadata.

-
-
- Selection is mature page state -

Files and KB documents preserve multi-selection, understand cardinality, and expose bulk handlers. Cmd+K can borrow this state instead of asking users to rediscover the same objects.

-
-
- Legality belongs to the page -

Existing actions already vary by permission and object state: Retry versus Cancel, Pause versus Resume, editable versus read-only. The palette should not duplicate those decisions.

-
-
-
- -
-

Topic Axes

-

Five ways the palette can become contextual

-
-
Page-local verbs

What can I do here, before I type?

-
Page-native entities

What finer-grained objects become searchable here?

-
Object and selection context

What am I already working on?

-
Result evidence and preview

How do I know this is the right match and act on it?

-
Contribution, routing, and permissions

How can pages add capability without turning SearchModal into a monolith?

-
-
- -
-

Ranked Ideas

-

Six directions worth pursuing

-

The ranking favors immediate user value and reuse of existing page capabilities, while preserving an architecture that can scale to every surface.

- - - -
- 1. -

Contextual Next Moves

-

Description Treat the current page, active object, and object state as an implicit zero-query. Cmd+K on Tables should begin with New table, New folder, and Import CSV; inside a KB it should begin with Add document, Add connector, or Sync; Files should offer Upload and New folder. Generic platform actions remain available, but page actions get the first positions.

-

Axis Page-local verbs

-

Basis direct: Tables, KBs, Files, Logs, and Scheduled Tasks already own guarded action handlers, while Cmd+K already proves the model with workflow-specific actions in search-modal/utils.ts.

-

Rationale Zero-query becomes useful without requiring users to remember command names. It is the smallest change that makes the palette feel aware of the place where it was opened.

-

Downsides Ordering can become noisy if every page promotes too many verbs. Each surface needs a deliberately small “next moves” set, not a mirror of every menu.

-
-
Confidence
95%
-
Complexity
Medium
-
Best first proof
Tables + KB detail
-
- -
- - The context stack that determines zero-query actions - The palette combines page, active object, selection, and state, then ranks contextual actions above platform fallback actions. - - - - - - - - PageTables / KBs - - ObjectCustomers / HR - - Selection12 documents - - StateFailed / paused - - - Contextual next moves - then platform fallback - Implicit query assembled before the user types - - -
Illustrative direction: four existing kinds of context combine into a small, ranked page-action set.
-
-
- -
- 2. -

Semantic Zoom Search

-

Description Search becomes more detailed as context becomes more specific. Globally, Cmd+K returns top-level workspace resources. Inside a KB, it can return documents and connectors; inside a table, saved views and matching rows; on Logs or Scheduled Tasks, executions and schedules. The inferred scope is visible and reversible, and deep results resolve asynchronously only after the query expresses intent.

-

Axis Page-native searchable entities

-

Basis direct: KB document and connector queries already exist in hooks/queries/kb/knowledge.ts and connectors.ts; table all-cell search and saved views already exist in hooks/queries/tables.ts. The palette currently receives pre-hydrated section arrays and caps groups to avoid per-keystroke stalls.

-

Rationale People often remember content rather than its container. Scoped resolution unlocks that recall path without imposing a workspace-wide data-loading tax.

-

Downsides Federated async results complicate loading, cancellation, ranking, and keyboard stability. Table row search should preserve its current explicit-submit behavior rather than firing expensive searches on every character.

-
-
Confidence
90%
-
Complexity
High
-
Best first proof
KB documents, then table rows
-
- - - - - - - - - - -
SurfacePage actionsScoped result typesUseful trailing evidence
Knowledge BaseAdd document, Add connector, SyncDocuments, connectorsSource, freshness, enabled state
TablesNew table, Import CSV, ExportSaved views, matching rowsMatched column and cell
FilesUpload, New folder, New fileFiles, foldersPath, type, modified time
LogsRefresh, ExportExecutionsStatus, workflow, duration
Scheduled TasksNew scheduled taskSchedulesCadence, next run, paused state
-
- -
- 3. -

Selection Is the Command Target

-

Description When Cmd+K opens over one or many selected objects, the selection becomes its active operand. Four selected files can offer Move, Download, or Delete; twelve KB documents can offer Enable, Disable, or Delete. Commands that require a single object disappear or explain why they are unavailable.

-

Axis Current-object and multi-selection context

-

Basis direct: Files and KB documents already hold Set-based selection, distinguish single from bulk actions, and dispatch cardinality-aware handlers in their page and context-menu components.

-

Rationale This turns Cmd+K into a keyboard-native complement to context menus. It accelerates work the user has already framed instead of resetting them to a global browser.

-

Downsides The palette must make its operand obvious—e.g. “12 documents selected”—and define what happens when route or selection changes while it is open.

-
-
Confidence
92%
-
Complexity
Medium
-
Best first proof
KB document bulk actions
-
-
- -
- 4. -

Actionable Match Receipts

-

Description Preserve compact rows, but make each result answer “why is this the right one?” The right side can show path, source, status, cadence, or result kind. The highlighted result can reveal a document excerpt, matching table cell, execution error, or connector provenance. A secondary action mode then offers result-owned operations such as Open, Copy link, Export, Retry, Pause, or Reconnect.

-

Axis Result presentation and preview

-

Basis direct: The current command row already has a trailing metadata slot, while native KB cards and execution previews already encode descriptions, provenance, status, and duration. Table, log, and schedule menus already define useful legal actions for their entities.

-

Rationale Deep search is only useful when users can distinguish similar matches and complete common operations without a navigation round-trip.

-

Downsides Evidence and action modes add focus and keyboard complexity. Start with typed trailing receipts, then highlighted evidence, then an action panel; do not make a wide Peek panel a prerequisite.

-
-
Confidence
88%
-
Complexity
Medium
-
Best first proof
Execution + KB document rows
-
-
- -
- 5. -

Operational Results

-

Description Use Logs and Scheduled Tasks as the proving ground for stateful results rather than static links. Failed executions can expose Retry and Open snapshot; running executions can expose Cancel; schedules can show cadence and next run with Pause or Resume. Integrations can later use the same pattern for Reconnect and connector Sync.

-

Axis Result presentation and page-native entities

-

Basis direct: Log actions already vary by execution state, schedule actions vary by lifecycle and recurrence, and credentials are already individually addressable search entities. The state and legal interventions exist today.

-

Rationale Operational users search because something needs attention. Status-aware results compress detection, navigation, and intervention into one interaction, giving this surface a clearer payoff than adding another passive page link.

-

Downsides Avoid a global “needs attention” feed initially—it would require cross-surface fetching or polling. Begin within Logs and Scheduled Tasks, where the data is already present and scoped.

-
-
Confidence
84%
-
Complexity
High
-
Best first proof
Logs: Retry / Cancel
-
-
- -
- 6. -

Page-Owned Command Contributions

-

Description Let each mounted page contribute a small command and result-resolver contract: label, applicable resource, visible, enabled, disabledReason, permission check, analytics identity, and execution behavior. The page remains authoritative for legality and data fetching; Cmd+K owns aggregation, ranking, rendering, focus, and global fallback.

-

Axis Contribution, routing, and permissions architecture

-

Basis direct: The workspace global-command provider already registers and invokes mounted handlers by ID. Cmd+K currently has coarse contexts and centrally assembled permission props, while deep domain queries already live in typed, cancellable React Query hooks.

-

Rationale This is the leverage point that lets every page become contextual without teaching SearchModal each page’s business logic or loading every possible entity up front.

-

Downsides A generic contract can become over-abstract before two real pages prove it. Build it from Tables and KB requirements, then test it with Logs before standardizing the API.

-
-
Confidence
93%
-
Complexity
High
-
Best first proof
Tables + KB contributions
-
- -
- Recommended architecture sequence -

Ship one page-action contribution first, add one scoped async resolver second, then add selection and result-owned actions. This keeps the contract shaped by real product needs.

-
-
-
- -
-

Rejection Summary

-

What was cut, merged, or deferred

-

The cuts favor a contextual core that can be proven incrementally. Ambitious parsing, indexing, and cross-route continuation should follow evidence that people use the simpler model.

-
- - - - - - - - - - - - - - - - - -
#IdeaReason rejected
1Current-object actionsMerged into Contextual Next Moves as the object-specific version of the same zero-query behavior.
2Page-local recents / ContinueNo shared recency or unfinished-work feed exists; page/object/state context is better grounded.
3KB deep searchMerged into Semantic Zoom Search as the strongest first pilot.
4Table deep searchMerged into Semantic Zoom Search as the second pilot, preserving explicit-submit search.
5Files deep searchFiles and folder paths are already searchable; richer path evidence belongs in Match Receipts.
6Explicit scope controlMerged into Semantic Zoom Search as a required interaction detail rather than a separate bet.
7Needs-attention groupDeferred: it requires cross-surface fetching or polling and is a ranking layer over operational entities.
8Wide Peek panelDeferred: substantial shell and layout cost before compact receipts and highlighted evidence are proven insufficient.
9Natural-language query compilerNo shared query-to-filter grammar exists; ambiguity and per-page implementation cost are high.
10Relationship searchNo general relationship index or cross-entity query layer exists yet.
11Search-to-createGeneric query-to-draft validation and handoff are not established across page types.
12Route-carried intentScope overrun: unmounted handlers currently fail and no general pending-intent protocol exists.
13Separate permission-execution layerMerged into Page-Owned Command Contributions; legality must be a core contract property, not a parallel system.
-
-
- -
Composed 2026-08-05T02:32Z by ce-ideate from the in-session Cmd+K page-context prompt and the current feat-enhance-cmd-k worktree.
-
- - From 9139781888d17fba149b7817891a49dc78281b66 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:00:51 -0700 Subject: [PATCH 15/29] fix(search): restore Ask Sim prefill and refine palette ranking --- .../search-modal/search-modal.test.tsx | 200 ++++++++++++++++-- .../components/search-modal/search-modal.tsx | 55 ++++- .../components/search-modal/utils.test.ts | 37 +++- .../sidebar/components/search-modal/utils.ts | 43 +++- 4 files changed, 307 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index ec8dafa4bda..dd51ea6d69a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -42,6 +42,15 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) +/** + * The real implementation runs the integration matcher over the block + * registry (globally mocked in jsdom); the palette only needs the store + * side effect, so delegate straight to LandingPromptStorage. + */ +vi.mock('@/blocks/integration-matcher', () => ({ + storeCuratedPrompt: (prompt: string) => LandingPromptStorage.store(prompt), +})) + vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ useInvokeGlobalCommand: () => vi.fn(), })) @@ -162,11 +171,8 @@ describe('SearchModal', () => { }) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home?handoff=1') - expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ - message: 'plan our Slack launch week', - contexts: undefined, - }) + expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') + expect(LandingPromptStorage.consume()).toBe('plan our Slack launch week') }) it('returns to search results when Tab is pressed again in ask mode', async () => { @@ -233,6 +239,32 @@ describe('SearchModal', () => { expect(rows[2]).toContain('Onboarding') }) + it('puts Create workflow first for the module-name query, then the workflows', async () => { + const workflows = [ + { id: 'workflow-a', name: 'Alpha', href: '/workspace/workspace-1/w/workflow-a' }, + { id: 'workflow-b', name: 'Beta', href: '/workspace/workspace-1/w/workflow-b' }, + ] + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('workflows') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Create workflow') + expect(rows[1]).toContain('Alpha') + expect(rows[2]).toContain('Beta') + }) + it('shows an empty state when search has no results', async () => { await act(async () => { root.render() @@ -272,12 +304,156 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() + expect(LandingPromptStorage.consume()).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } }) + it('puts the Start Trigger first when the query is its exact name', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + triggers: [{ id: 'start', name: 'Start', icon: Icon, bgColor: '#111', type: 'start' }], + toolOperations: [ + { + id: 'browser_start_task', + name: 'Start Task', + serviceName: 'Browser', + searchValue: 'browser start-task', + icon: Icon, + bgColor: '#611f69', + blockType: 'browser', + operationId: 'start_task', + }, + ], + } + const workflows = [ + { id: 'workflow-start', name: 'Start', href: '/workspace/workspace-1/w/workflow-start' }, + ] + + try { + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('start') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Start Trigger') + expect(rows.some((row) => row.includes('Start Task'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('puts the workflow verb actions first for their bare-verb queries', async () => { + const Icon = () => null + const original = { ...mockSearchState.data } + mockSearchState.data = { + ...mockSearchState.data, + toolOperations: [ + { + id: 'vercel_deploy', + name: 'Deploy', + serviceName: 'Vercel', + searchValue: 'vercel deploy', + icon: Icon, + bgColor: '#000', + blockType: 'vercel', + operationId: 'deploy', + }, + { + id: 'sheets_copy', + name: 'Copy', + serviceName: 'Sheets', + searchValue: 'sheets copy', + icon: Icon, + bgColor: '#0f9d58', + blockType: 'sheets', + operationId: 'copy', + }, + ], + } + + try { + await act(async () => { + root.render() + }) + + await enterSearchQuery('deploy') + let rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Deploy workflow') + expect(rows.some((row) => row.includes('Vercel'))).toBe(true) + + await enterSearchQuery('copy') + rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Copy workflow link') + expect(rows.some((row) => row.includes('Sheets'))).toBe(true) + } finally { + mockSearchState.data = original + } + }) + + it('browses every section uncapped', async () => { + const Icon = () => null + const workflows = Array.from({ length: 10 }, (_, index) => ({ + id: `workflow-${index}`, + name: `Zeta ${index}`, + href: `/workspace/workspace-1/w/workflow-${index}`, + })) + const integrations = Array.from({ length: 30 }, (_, index) => ({ + id: `catalog-${index}`, + name: `Acme ${index}`, + href: `/workspace/workspace-1/integrations/catalog-${index}`, + icon: Icon, + bgColor: '#111', + })) + + await act(async () => { + root.render( + + ) + }) + + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows.filter((row) => /Zeta \d/.test(row))).toHaveLength(10) + expect(rows.filter((row) => /Acme \d/.test(row))).toHaveLength(30) + }) + + it('ranks a matched action above an exact-named entity from another section', async () => { + const workflows = [ + { id: 'workflow-run', name: 'Run', href: '/workspace/workspace-1/w/workflow-run' }, + ] + await act(async () => { + root.render( + + ) + }) + + await enterSearchQuery('run') + const rows = Array.from(document.querySelectorAll('[cmdk-item]')).map( + (el) => el.textContent ?? '' + ) + expect(rows[0]).toContain('Run workflow') + expect(rows.some((row) => row.includes('Run') && !row.includes('Run workflow'))).toBe(true) + }) + it('orders canvas browse groups as Actions, Sim, building blocks, then the standard tail', async () => { const Icon = () => null const block = { id: 'agent', name: 'Agent', icon: Icon, bgColor: '#111', type: 'agent' } @@ -330,16 +506,16 @@ describe('SearchModal', () => { const headings = Array.from( document.querySelectorAll('[cmdk-group-heading]') ).map((el) => el.textContent) - expect(headings.slice(0, 8)).toEqual([ + expect(headings.slice(0, 7)).toEqual([ 'Actions', 'Sim', 'Blocks', 'Triggers', 'Tools', - 'Tool operations', 'Pages', 'Workflows', ]) + expect(headings).not.toContain('Tool operations') expect(headings).not.toContain('Integrations') expect(headings).not.toContain('Connected Integrations') } finally { @@ -440,7 +616,7 @@ describe('SearchModal', () => { expect(document.querySelector('input[aria-label="Ask Sim"]')).toBeNull() }) - it('bounds browse rows while keeping later tool operations searchable', async () => { + it('hides tool operations in browse but keeps them searchable', async () => { const Icon = () => null const original = { ...mockSearchState.data } mockSearchState.data = { @@ -465,7 +641,7 @@ describe('SearchModal', () => { const browseRows = Array.from(document.querySelectorAll('[cmdk-item]')).filter( (row) => row.textContent?.includes('Operation') ) - expect(browseRows).toHaveLength(8) + expect(browseRows).toHaveLength(0) await enterSearchQuery('Operation') expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(50) @@ -524,7 +700,7 @@ describe('SearchModal', () => { it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) try { await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 76c5e1513f0..c37b00ea53b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -40,12 +40,10 @@ import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { getMothershipHandoffHref } from '@/app/workspace/[workspaceId]/home/search-params' import { useInvokeGlobalCommand } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { CommandFadedList, @@ -85,13 +83,20 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' +import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSearchModalStore } from '@/stores/modals/search/store' import type { SearchBlockItem, SearchToolOperationItem } from '@/stores/modals/search/types' const logger = createLogger('SearchModal') -const MAX_BROWSE_RESULTS_PER_GROUP = 8 +/** + * Global row budget for the browse (empty-query) list, applied cumulatively in + * section order. Individual sections are never capped in browse — the budget + * exists purely to bound render cost when the combined lists are huge. + * Currently disabled (Infinity); set a finite number to re-enable the bound. + */ +export const MAX_BROWSE_RESULTS = Number.POSITIVE_INFINITY const MAX_SEARCH_RESULTS = 50 export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' @@ -248,6 +253,7 @@ function SearchModalContent({ id: 'deploy-workflow', name: 'Deploy workflow', keywords: 'ship release publish api', + exactQueries: ['deploy'], icon: Rocket, context: 'workflow', run: invoke('deploy-workflow'), @@ -267,6 +273,7 @@ function SearchModalContent({ id: 'copy-workflow-url', name: 'Copy workflow link', keywords: 'url share clipboard', + exactQueries: ['copy'], icon: Duplicate, context: 'workflow', run: () => { @@ -290,7 +297,8 @@ function SearchModalContent({ list.push({ id: 'create-workflow', name: 'Create workflow', - keywords: 'new add build', + keywords: 'new add build workflows', + exactQueries: ['workflows'], icon: Plus, context: 'global', run: onCreateWorkflow, @@ -831,13 +839,18 @@ function SearchModalContent({ const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + /* Prefill (not auto-send) via the same seam as the integrations "Explore" + showcase: seed the chat input on the freshly mounted home surface. The + MothershipHandoffStorage auto-send path drops sends started during + Home's mount-settling window (use-chat's cleanup abort), so the message + would silently vanish. */ + if (!storeCuratedPrompt(query)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) return } - routerRef.current.push(getMothershipHandoffHref(workspaceId)) + routerRef.current.push(homeHref) } onOpenChangeRef.current(false) @@ -879,14 +892,14 @@ function SearchModalContent({ ) => query ? scoreSectionItems(section, items, toValue, search, toExtra, MAX_RESULTS_PER_GROUP) - : items.slice(0, MAX_BROWSE_RESULTS_PER_GROUP).map((item) => ({ item, score: 0 })) + : items.map((item) => ({ item, score: 0 })) const availableActions = actions.filter( (action) => action.context === 'global' || action.context === pageContext ) const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => query ? scoreActions(items, search, MAX_RESULTS_PER_GROUP, groupLabel) - : items.slice(0, MAX_BROWSE_RESULTS_PER_GROUP).map((item) => ({ item, score: 0 })) + : items.map((item) => ({ item, score: 0 })) const pageGroupLabel = pageContext ? ('Actions' as const) : null const rankedActions = [ ...(pageGroupLabel @@ -926,21 +939,30 @@ function SearchModalContent({ onCanvas ? triggers.map((trigger) => ({ ...trigger, + baseName: trigger.name, name: trigger.name.endsWith('Trigger') ? trigger.name : `${trigger.name} Trigger`, })) : [], (item) => item.name, (item) => `${toSearchToken(item.name)} ${item.id}` - ).map(({ item, score }) => ({ section: 'triggers', item, score })), + ).map(({ item, score }) => ({ + section: 'triggers', + item, + /* The display rename ("Start" → "Start Trigger") costs the exact-name + bonus, so a query that IS the trigger's name ranks it like a page row. */ + score: item.baseName.toLowerCase() === query.toLowerCase() ? PAGE_MATCH_TIER : score, + })), tools: rank( 'tools', availableTools, (item) => item.name, (item) => item.searchValue ).map(({ item, score }) => ({ section: 'tools', item, score })), + /* Tool operations are the one huge list (1000+ rows); browsing them + uncapped makes modal open/close laggy, so they are search-only. */ toolOperations: rank( 'toolOperations', - onCanvas ? toolOperations : [], + onCanvas && query ? toolOperations : [], (item) => item.name, (item) => item.searchValue ).map(({ item, score }) => ({ section: 'toolOperations', item, score })), @@ -1056,7 +1078,7 @@ function SearchModalContent({ const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined const canvasSections = new Set(CANVAS_SECTIONS) - return [ + const groups = [ ...(pageGroupLabel ? [ { @@ -1077,6 +1099,17 @@ function SearchModalContent({ (section) => section !== 'actions' && section !== hoisted && !canvasSections.has(section) ).map(entityGroup), ] + + let remaining = MAX_BROWSE_RESULTS + return groups.map((group) => { + if (group.entries.length <= remaining) { + remaining -= group.entries.length + return group + } + const truncated = { ...group, entries: group.entries.slice(0, remaining) } + remaining = 0 + return truncated + }) }, [entriesBySection, pageContext]) const entryHandlers = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index 20e4cbcd0c7..55b51aee401 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + ACTION_MATCH_BIAS, filterAndCap, filterAndSort, fuzzyMatch, @@ -74,7 +75,7 @@ describe('getGlobalSearchResults', () => { expect(matches.map((entry) => entry.item.id)).toEqual(['chat-1', 'workflow-1', 'create-folder']) }) - it('breaks identical visible-name matches by the original section order', () => { + it('biases matched actions above equal-quality matches from entity sections', () => { const action = { id: 'new-chat-action', name: 'New chat', @@ -87,7 +88,7 @@ describe('getGlobalSearchResults', () => { const [actionMatch] = scoreActions([action], 'new c') const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c') - expect(actionMatch.score).toBe(chatMatch.score) + expect(actionMatch.score).toBe(chatMatch.score + ACTION_MATCH_BIAS) expect( getGlobalSearchResults( { @@ -99,6 +100,24 @@ describe('getGlobalSearchResults', () => { ).toEqual(['new-chat-action', 'new-chat-result']) }) + it('breaks identical visible-name matches by the original section order', () => { + const workflow = { id: 'new-chat-workflow', name: 'New chat', href: '/new-chat-workflow' } + const chat = { id: 'new-chat-result', name: 'New chat', href: '/new-chat-result' } + const [workflowMatch] = scoreAndSort([workflow], (item) => item.name, 'new c') + const [chatMatch] = scoreAndSort([chat], (item) => item.name, 'new c') + + expect(workflowMatch.score).toBe(chatMatch.score) + expect( + getGlobalSearchResults( + { + workflows: [{ section: 'workflows', ...workflowMatch }], + chats: [{ section: 'chats', ...chatMatch }], + }, + ['workflows', 'chats'] + ).map((entry) => entry.item.id) + ).toEqual(['new-chat-workflow', 'new-chat-result']) + }) + it('keeps every matching entry in score order', () => { const workflows: SearchEntry[] = Array.from({ length: 8 }, (_, index) => ({ section: 'workflows', @@ -149,6 +168,20 @@ describe('scoreSectionItems', () => { ).toEqual(['Workspaces demo', 'Acme', 'Beta']) }) + it('never fills or lifts tool operations from their section label', () => { + const operations = [{ name: 'Send Message' }, { name: 'Create Row' }] + + expect( + scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool operations') + ).toHaveLength(0) + expect(scoreSectionItems('toolOperations', operations, (op) => op.name, 'tool')).toHaveLength(0) + expect( + scoreSectionItems('toolOperations', operations, (op) => op.name, 'send').map( + ({ item }) => item.name + ) + ).toEqual(['Send Message']) + }) + it('lifts a whole section above other sections’ name matches when the query is exactly its name', () => { const workflowItems = [ { name: 'Onboarding' }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index e3c95dcb265..7bf6973a877 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -22,8 +22,8 @@ export const SEARCH_SECTIONS = [ 'knowledgeBases', 'logs', 'connectedAccounts', - 'integrations', 'chats', + 'integrations', ] as const /** A single search-modal result group. */ @@ -127,6 +127,14 @@ export interface ActionItem { name: string /** Extra terms folded into the search value (e.g. "new add"). */ keywords?: string + /** + * Lowercase queries that name this action outright — the module it heads + * (`'workflows'` for Create workflow) or its bare verb (`'deploy'`, + * `'copy'`). When the trimmed query IS one of these, the action ranks like + * a page row ({@link PAGE_MATCH_TIER}), above section-lifted and + * exact-name entity rows. + */ + exactQueries?: readonly string[] icon: ComponentType<{ className?: string }> shortcut?: string context: ActionContext @@ -557,6 +565,14 @@ function scoreItemsForSection( return results } +/** + * Sections whose label never participates in matching. Tool operations are a + * 1000+ registry-ordered list, so label-driven behavior ("tool operations" + * lifting the section, or a partial hit like "tool" filling it) would surface + * arbitrary rows; individual operations stay searchable by name and alias. + */ +const LABEL_MATCH_EXEMPT_SECTIONS = new Set(['toolOperations']) + export function scoreSectionItems( section: SearchSection, items: T[], @@ -565,16 +581,34 @@ export function scoreSectionItems( toExtra?: (item: T) => string | undefined, maxResults = Number.POSITIVE_INFINITY ): Array<{ item: T; score: number }> { + if (LABEL_MATCH_EXEMPT_SECTIONS.has(section)) { + return scoreAndSort(items, toValue, search, toExtra).slice(0, maxResults) + } return scoreItemsForSection(SECTION_LABELS[section], items, toValue, search, toExtra, maxResults) } -/** Scores actions by visible name before falling back to their keywords. */ +/** + * Rank offset added to every matched action. Actions are the palette's few + * runnable verbs, so a matched action outranks entity rows of the same match + * quality — a name-matched action beats name-matched entities, a + * keyword-matched action beats other secondary-text matches — while the + * half-tier offset deliberately cannot bridge into the next tier up + * ({@link SECTION_MATCH_TIER}, {@link PAGE_MATCH_TIER}). + */ +export const ACTION_MATCH_BIAS = 500_000 + +/** + * Scores actions by visible name before falling back to their keywords. + * Every match is lifted by {@link ACTION_MATCH_BIAS}; a query listed in the + * action's `exactQueries` ranks it like a page row instead. + */ export function scoreActions( actions: ActionItem[], search: string, maxResults = Number.POSITIVE_INFINITY, groupLabel: ActionGroupLabel = 'Sim' ): Array<{ item: ActionItem; score: number }> { + const query = search.trim().toLowerCase() return scoreItemsForSection( groupLabel, actions, @@ -582,7 +616,10 @@ export function scoreActions( search, (action) => `${toSearchToken(action.name)} ${action.keywords ?? ''}`, maxResults - ) + ).map(({ item, score }) => ({ + item, + score: item.exactQueries?.includes(query) ? PAGE_MATCH_TIER : score + ACTION_MATCH_BIAS, + })) } /** From 160d500ed998374d4e590d2f53325691e399c087 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:53:53 -0700 Subject: [PATCH 16/29] chore(sidebar): drop unused getSettingsHref destructure --- .../w/components/sidebar/sidebar.tsx | 406 +++++++++--------- 1 file changed, 202 insertions(+), 204 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index a973415d9a1..2feb5f2d7fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -20,34 +20,33 @@ import { Upload, } from '@sim/emcn' import { - BookOpen, - Calendar, Database, Files, - HelpCircle, Integration, + MoreHorizontal, PanelLeft, + Pin, Plus, Search, - Settings, Table, Task, Workflow, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { MoreHorizontal, Pin } from 'lucide-react' import Link from 'next/link' import { useParams, usePathname, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { SlackIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' import { isChatEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' -import { buildFolderTree, getFolderPath } from '@/lib/folders/tree' +import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree' import { captureEvent } from '@/lib/posthog/client' +import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' import { CollapsedChatFlyoutItem, @@ -58,6 +57,8 @@ import { NavItemContextMenu, SearchModal, SettingsSidebar, + SidebarFooter, + SidebarSection, WorkflowList, WorkspaceHeader, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' @@ -72,6 +73,8 @@ import type { import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, SIDEBAR_ITEM_GAP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' @@ -131,6 +134,7 @@ const logger = createLogger('Sidebar') * invalidate every memo downstream of it. */ const EMPTY_CHATS: MothershipChatMetadata[] = [] +/** Stable identity while a folder list loads, so the search-row memos don't churn. */ const EMPTY_FOLDER_MAP: Record = {} /** Recent runs shown in the palette's Logs section on the logs pages. */ @@ -181,9 +185,10 @@ export function SidebarTooltip({ ) } +/** Stands in for a chip row while a list loads, so it carries no margin either. */ function SidebarItemSkeleton() { return ( -
+
) @@ -218,6 +223,13 @@ const SidebarChatItem = memo(function SidebarChatItem({ }) { const dragGhostRef = useRef(null) + /** + * The trailing slot fits one glyph, and the dot wins over the pin: it reports + * transient state (a run in progress, or an unread reply elsewhere), while pinning + * is persistent and already conveyed by the row sorting to the top of the list. + */ + const showStatusDot = isActive || (!isCurrentRoute && isUnread) + function handleDragStart(e: React.DragEvent) { e.dataTransfer.effectAllowed = 'copyMove' e.dataTransfer.setData( @@ -261,12 +273,12 @@ const SidebarChatItem = memo(function SidebarChatItem({ >
{chat.name}
{chat.id !== 'new' && ( -
- {(isActive || (!isCurrentRoute && isUnread)) && ( +
+ {showStatusDot && (
)} @@ -381,6 +396,13 @@ export const SIDEBAR_SCROLL_EVENT = 'sidebar-scroll-to-item' const HIDDEN_STYLE = { display: 'none' } as const +/** + * Opts a control out of the desktop shell's window-drag region. The header row is + * draggable chrome, so anything clickable inside it has to say so or the click is + * swallowed by the drag handler. + */ +const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' + /** * Sidebar component with resizable width that persists across page refreshes. * @@ -428,9 +450,15 @@ export const Sidebar = memo(function Sidebar({ const posthog = usePostHog() const { data: sessionData, isPending: sessionLoading } = useSession() + const { workspace: routeWorkspace } = useWorkspaceHostContext() const { canAdmin, canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() - const { config: permissionConfig, filterBlocks } = usePermissionConfig() - const { navigateToSettings, getSettingsHref } = useSettingsNavigation() + const { + config: permissionConfig, + filterBlocks, + isBlockAllowed, + integrationAvailability, + } = usePermissionConfig() + const { navigateToSettings } = useSettingsNavigation() const initializeSearchData = useSearchModalStore((state) => state.initializeData) const customBlockOverlayVersion = useCustomBlockOverlayVersion() const providers = useProvidersStore((state) => state.providers) @@ -519,6 +547,8 @@ export const Sidebar = memo(function Sidebar({ const { workspaces, + pinnedWorkspaceIds, + toggleWorkspacePin, workspaceCreationPolicy, activeWorkspace, isWorkspacesLoading, @@ -567,7 +597,17 @@ export const Sidebar = memo(function Sidebar({ }) useFolders(workspaceId) - const { data: folderMap = {} } = useFolderMap(workspaceId) + const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId) + // Tables and knowledge bases keep their folders in the generic folder tree, + // keyed by resource type, so each needs its own map to resolve a path. + const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideTablesTab ? undefined : workspaceId, + 'table' + ) + const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( + permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId, + 'knowledge_base' + ) const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( @@ -741,18 +781,13 @@ export const Sidebar = memo(function Sidebar({ const searchModalWorkflows = useMemo( () => - regularWorkflows.map((workflow) => { - const folderPath = workflow.folderId - ? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name) - : [] - return { - id: workflow.id, - name: workflow.name, - href: `/workspace/${workspaceId}/w/${workflow.id}`, - folderPath: folderPath.length > 0 ? folderPath : undefined, - isCurrent: workflow.id === workflowId, - } - }), + regularWorkflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + href: `/workspace/${workspaceId}/w/${workflow.id}`, + folderPath: getFolderPathNames(folderMap, workflow.folderId), + isCurrent: workflow.id === workflowId, + })), [regularWorkflows, folderMap, workspaceId, workflowId] ) @@ -782,29 +817,18 @@ export const Sidebar = memo(function Sidebar({ // on a workflow the server declined to create. hidden: !isChatEnabled && !permissionsLoading && !canEdit, }, - { - id: 'search', - label: 'Search', - icon: Search, - onClick: openSearchModal, - }, { id: 'integrations', label: 'Integrations', icon: Integration, href: `/workspace/${workspaceId}/integrations`, + /* Skills is a tab of this surface, not its own nav item — keep the entry + lit while the user is on it. */ additionalActivePaths: [`/workspace/${workspaceId}/skills`], hidden: permissionConfig.hideIntegrationsTab, }, ].filter((item) => !item.hidden), - [ - workspaceId, - openSearchModal, - createWorkflow, - canEdit, - permissionsLoading, - permissionConfig.hideIntegrationsTab, - ] + [workspaceId, createWorkflow, canEdit, permissionsLoading, permissionConfig.hideIntegrationsTab] ) const workspaceNavItems = useMemo( @@ -831,13 +855,6 @@ export const Sidebar = memo(function Sidebar({ href: `/workspace/${workspaceId}/knowledge`, hidden: permissionConfig.hideKnowledgeBaseTab, }, - { - id: 'scheduled-tasks', - label: 'Scheduled tasks', - icon: Calendar, - href: `/workspace/${workspaceId}/scheduled-tasks`, - hidden: !isChatEnabled, - }, { id: 'logs', label: 'Logs', @@ -853,22 +870,14 @@ export const Sidebar = memo(function Sidebar({ ] ) - const footerItems = useMemo( - () => [ - { - id: 'settings', - label: 'Settings', - icon: Settings, - href: getSettingsHref(), - onClick: () => { - if (!isCollapsedRef.current) { - setSidebarWidth(SIDEBAR_WIDTH.MIN) - } - navigateToSettings() - }, - }, - ], - [navigateToSettings, getSettingsHref, setSidebarWidth] + const handleOpenSettings = useCallback( + (section: SettingsSection) => { + if (!isCollapsedRef.current) { + setSidebarWidth(SIDEBAR_WIDTH.MIN) + } + navigateToSettings({ section }) + }, + [navigateToSettings, setSidebarWidth] ) const { data: fetchedChats = EMPTY_CHATS, isLoading: chatsLoading } = useMothershipChats( @@ -895,27 +904,17 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedTables = [] } = useTablesList(workspaceId) const { data: fetchedFiles = [] } = useWorkspaceFiles(workspaceId) const { data: fetchedKnowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId) - const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId, 'table') - const { data: knowledgeFolderMap = EMPTY_FOLDER_MAP } = useFolderMap( - workspaceId, - 'knowledge_base' - ) const searchModalTables = useMemo( () => permissionConfig.hideTablesTab ? [] - : fetchedTables.map((t) => { - const folderPath = t.folderId - ? getFolderPath(tableFolderMap, t.folderId).map((folder) => folder.name) - : [] - return { - id: t.id, - name: t.name, - href: `/workspace/${workspaceId}/tables/${t.id}`, - folderPath: folderPath.length > 0 ? folderPath : undefined, - } - }), + : fetchedTables.map((t) => ({ + id: t.id, + name: t.name, + href: `/workspace/${workspaceId}/tables/${t.id}`, + folderPath: getFolderPathNames(tableFolderMap, t.folderId), + })), [fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab] ) @@ -936,18 +935,18 @@ export const Sidebar = memo(function Sidebar({ () => permissionConfig.hideKnowledgeBaseTab ? [] - : fetchedKnowledgeBases.map((kb) => { - const folderPath = kb.folderId - ? getFolderPath(knowledgeFolderMap, kb.folderId).map((folder) => folder.name) - : [] - return { - id: kb.id, - name: kb.name, - href: `/workspace/${workspaceId}/knowledge/${kb.id}`, - folderPath: folderPath.length > 0 ? folderPath : undefined, - } - }), - [fetchedKnowledgeBases, knowledgeFolderMap, workspaceId, permissionConfig.hideKnowledgeBaseTab] + : fetchedKnowledgeBases.map((kb) => ({ + id: kb.id, + name: kb.name, + href: `/workspace/${workspaceId}/knowledge/${kb.id}`, + folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId), + })), + [ + fetchedKnowledgeBases, + knowledgeBaseFolderMap, + workspaceId, + permissionConfig.hideKnowledgeBaseTab, + ] ) const chatIds = useMemo(() => chats.map((t) => t.id), [chats]) @@ -1085,7 +1084,6 @@ export const Sidebar = memo(function Sidebar({ ) const [hasOverflowTop, setHasOverflowTop] = useState(false) - const [hasOverflowBottom, setHasOverflowBottom] = useState(false) useEffect(() => { const container = scrollContainerRef.current @@ -1093,9 +1091,6 @@ export const Sidebar = memo(function Sidebar({ const updateScrollState = () => { setHasOverflowTop(container.scrollTop > 1) - setHasOverflowBottom( - container.scrollHeight > container.scrollTop + container.clientHeight + 1 - ) } updateScrollState() @@ -1137,7 +1132,6 @@ export const Sidebar = memo(function Sidebar({ if (pathname === `${base}/knowledge`) return 'knowledge' if (detailSegment(`${base}/knowledge/`)) return 'knowledgeBase' if (pathname === `${base}/logs`) return logsViewMode === 'dashboard' ? 'logsDashboard' : 'logs' - if (pathname === `${base}/scheduled-tasks`) return 'scheduledTasks' return null }, [pathname, workspaceId, workflowId, logsViewMode]) @@ -1162,8 +1156,17 @@ export const Sidebar = memo(function Sidebar({ }, [logsPages.data, workspaceId]) const searchModalIntegrations = useMemo( - () => (permissionConfig.hideIntegrationsTab ? [] : buildIntegrationSearchItems(workspaceId)), - [workspaceId, permissionConfig.hideIntegrationsTab] + () => + permissionConfig.hideIntegrationsTab + ? [] + : buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => { + const availability = integrationAvailability.get(blockType.toLowerCase()) + if (!availability) return CONNECT_MODE.oauth + if (availability?.oauthAvailable) return CONNECT_MODE.oauth + if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount + return null + }), + [workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability] ) const searchModalConnectedAccounts = useMemo( @@ -1402,7 +1405,7 @@ export const Sidebar = memo(function Sidebar({ />
{isOnSettingsPage ? ( @@ -1475,7 +1512,8 @@ export const Sidebar = memo(function Sidebar({ className={cn( SIDEBAR_SECTION_GAP_CLASS, SIDEBAR_ITEM_GAP_CLASS, - 'flex flex-shrink-0 flex-col px-2 pb-1.5' + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + 'flex flex-shrink-0 flex-col px-2' )} > {topNavItems.map((item) => ( @@ -1492,22 +1530,23 @@ export const Sidebar = memo(function Sidebar({
{isChatEnabled && ( -
-
-
Chats
-
+ {isCollapsed ? ( {chatsLoading ? ( @@ -1538,7 +1577,7 @@ export const Sidebar = memo(function Sidebar({ )} ) : ( -
+
{chatsLoading ? ( ) : ( @@ -1549,10 +1588,10 @@ export const Sidebar = memo(function Sidebar({
) : null} {/* `selectChatOnly` populates `selectedChats` on every click, so - a single entry just means "last clicked" — already conveyed by - `isCurrentRoute`. Highlight from selection only for explicit - multi-selection (size > 1), otherwise it lingers after navigating - away from a chat. */} + a single entry just means "last clicked" — already conveyed by + `isCurrentRoute`. Highlight from selection only for explicit + multi-selection (size > 1), otherwise it lingers after navigating + away from a chat. */} {chats.slice(0, visibleChatCount).map((chat) => { const isCurrentRoute = pathname === chat.href const isRenaming = chatFlyoutRename.editingId === chat.id @@ -1619,13 +1658,14 @@ export const Sidebar = memo(function Sidebar({ )}
)} -
+ )} -
-
-
Workspace
-
+
{workspaceNavItems.map((item) => ( ))}
-
- -
-
-
Workflows
- {!isCollapsed && ( + + + @@ -1655,13 +1692,13 @@ export const Sidebar = memo(function Sidebar({ @@ -1695,7 +1732,7 @@ export const Sidebar = memo(function Sidebar({
- )} -
+ ) + } + > {isCollapsed ? ( {workflowsLoading && regularWorkflows.length === 0 ? ( @@ -1776,7 +1813,7 @@ export const Sidebar = memo(function Sidebar({ )} ) : ( -
+
{workflowsLoading && regularWorkflows.length === 0 ? ( ) : ( @@ -1794,58 +1831,19 @@ export const Sidebar = memo(function Sidebar({ )}
)} -
+
-
- - - - - - - - - - Docs - - - - Slack Community - - - - Report an issue - - - - - {footerItems.map((item) => ( - - ))} -
+ Date: Mon, 10 Aug 2026 19:23:18 -0700 Subject: [PATCH 17/29] fix(deploy): gate every deploy invoker on full eligibility --- .../[workflowId]/components/panel/components/deploy/deploy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 42a0a7c5891..e3b0fcbe100 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -63,7 +63,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: (!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing) const onDeployClick = async () => { - if (disabled || !canDeploy || !activeWorkflowId) return + if (isDisabled || !activeWorkflowId) return if (isDeploymentSettling) { setIsModalOpen(true) From c1bd985e740d2e6d0b397fcc114aa50ef1c005f7 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:43:31 -0700 Subject: [PATCH 18/29] fix(search): port the palette to post-#6458 staging Carries the former merge resolutions as one commit: the emcn icon set (SelectAll fit-to-view, Search chrome), native browser-panel occlusion gating, scheduled-tasks retirement, the chip-aware handoff consumer superseding the ?handoff=1 machinery, and Knowledge bases pluralization. --- .../app/workspace/[workspaceId]/home/home.tsx | 206 ++++++++++----- .../[workspaceId]/home/hooks/index.ts | 1 - .../hooks/use-mothership-handoff.test.tsx | 79 ------ .../home/hooks/use-mothership-handoff.ts | 41 --- .../[workspaceId]/home/search-params.ts | 17 -- .../[workspaceId]/knowledge/knowledge.tsx | 249 ++++++++++-------- .../components/log-details/log-details.tsx | 3 +- .../scheduled-tasks/scheduled-tasks.tsx | 236 ----------------- .../command-chrome/command-chrome.tsx | 2 +- .../command-items/command-items.tsx | 18 +- .../components/search-modal/search-modal.tsx | 64 +++-- .../sidebar/components/search-modal/utils.ts | 22 +- .../w/components/sidebar/sidebar.tsx | 2 +- apps/sim/stores/modals/search/types.ts | 33 ++- 14 files changed, 368 insertions(+), 605 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/scheduled-tasks/scheduled-tasks.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index af79ad47245..1e6ef10d6ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -10,27 +10,33 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from 'react' -import { Button, cn } from '@sim/emcn' +import { Button, cn, toast } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { LandingPromptStorage, type LandingWorkflowSeed, LandingWorkflowSeedStorage, + MothershipHandoffStorage, } from '@/lib/core/utils/browser-storage' +import { isDesktopApp } from '@/lib/desktop' import { + addMothershipContexts, MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { persistImportedWorkflow } from '@/lib/workflows/operations/import-export' +import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' +import { resolveWorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/resolve-resource-ref' import { resourceParam, resourceUrlKeys } from '@/app/workspace/[workspaceId]/home/search-params' import { useFolders } from '@/hooks/queries/folders' import { @@ -38,7 +44,7 @@ import { useMothershipChatHistory, } from '@/hooks/queries/mothership-chats' import { useWorkflows } from '@/hooks/queries/workflows' -import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { getWorkspaceFilesQueryOptions, useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useOAuthReturnRouter } from '@/hooks/use-oauth-return' import type { ChatContext } from '@/stores/panel' import { @@ -50,15 +56,17 @@ import { UserInput, type UserInputHandle, } from './components' -import { - getMothershipUseChatOptions, - useChat, - useMothershipHandoff, - useMothershipResize, -} from './hooks' -import type { FileAttachmentForApi, MothershipResource, MothershipResourceType } from './types' +import { getMothershipUseChatOptions, useChat, useMothershipResize } from './hooks' +import type { + FileAttachmentForApi, + MothershipResource, + MothershipResourceType, + WorkspaceResourceRef, +} from './types' const logger = createLogger('Home') +const subscribeToDesktopApp = () => () => {} +const getServerDesktopAppSnapshot = () => false /** * The resource preview panel pulls in the file-viewer stack (rich-markdown @@ -81,8 +89,14 @@ interface HomeProps { export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) { useOAuthReturnRouter() + const isDesktop = useSyncExternalStore( + subscribeToDesktopApp, + isDesktopApp, + getServerDesktopAppSnapshot + ) const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() + const queryClient = useQueryClient() /** * URL is the single source of truth for the selected resource. `Home` renders * client-side, so nuqs reads `?resource=` from the URL on mount — the same @@ -196,18 +210,11 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const { isPending: isChatHistoryPending } = useMothershipChatHistory(chatId) const { mutate: markRead } = useMarkMothershipChatRead(workspaceId) - const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize() - const [isResourceCollapsed, setIsResourceCollapsed] = useState(true) const [skipResourceTransition, setSkipResourceTransition] = useState(false) const isResourceCollapsedRef = useRef(isResourceCollapsed) isResourceCollapsedRef.current = isResourceCollapsed - const collapseResource = useCallback(() => { - clearWidth() - setIsResourceCollapsed(true) - }, [clearWidth]) - function handleResourceEvent() { if (isResourceCollapsedRef.current) { setIsResourceCollapsed(false) @@ -221,6 +228,7 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) sendMessage, stopGeneration, resolvedChatId, + desktopScopeId, resources, activeResourceId, setActiveResourceId, @@ -254,7 +262,12 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) }) ) - useMothershipHandoff({ chatId, workspaceId, sendMessage }) + const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize(desktopScopeId) + + const collapseResource = useCallback(() => { + clearWidth() + setIsResourceCollapsed(true) + }, [clearWidth]) useEffect(() => { wasSendingRef.current = false @@ -381,60 +394,101 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) return context.knowledgeId ? { type: 'knowledgebase', id: context.knowledgeId } : null case 'table': return context.tableId ? { type: 'table', id: context.tableId } : null + case 'table_selection': + return context.tableId ? { type: 'table', id: context.tableId } : null case 'file': return context.fileId ? { type: 'file', id: context.fileId } : null + case 'file_selection': + return context.fileId ? { type: 'file', id: context.fileId } : null default: return null } } + /** + * Tab title for the resource a chip opens. A selection chip's label describes + * the selection (`notes.md:12-40`, `Sales (3 rows)`) but the tab shows the + * whole file/table, so title it from the resource name the context carries. + */ + function resourceTitleForContext(context: ChatContext): string { + if (context.kind === 'file_selection') return context.fileName + if (context.kind === 'table_selection') return context.tableName + return context.label + } + function handleContextAdd(context: ChatContext) { const resolved = resolveResourceFromContext(context) if (resolved) { - addResource({ ...resolved, title: context.label }) + addResource({ ...resolved, title: resourceTitleForContext(context) }) handleResourceEvent() } } - function handleInitialContextRemove(context: ChatContext) { + function handleInitialContextRemove(context: ChatContext, remaining: ChatContext[]) { const resolved = resolveResourceFromContext(context) if (!resolved) return + // A whole-file chip and one or more of its selection chips (or several + // selections of the same file/table) all resolve to the same resource tab. + // Only close the tab once no remaining chip still references it, so removing + // one of several chips doesn't yank a slideover the others still point at. + const stillReferenced = remaining.some((other) => { + const otherResolved = resolveResourceFromContext(other) + return otherResolved?.type === resolved.type && otherResolved.id === resolved.id + }) + if (stillReferenced) return removeResource(resolved.type, resolved.id) } - const resolveFileResource = useCallback( - (resource: MothershipResource): MothershipResource => { - if (resource.type !== 'file') return resource - - const reference = (resource.path || resource.id).trim() - - const file = workspaceFiles.find((candidate) => { - const candidatePath = canonicalWorkspaceFilePath({ - folderPath: candidate.folderPath, - name: candidate.name, - }) - return candidate.id === reference || candidatePath === reference - }) - - if (!file) return resource - return { - ...resource, - id: file.id, - title: resource.title || file.name, - } - }, - [workspaceFiles] - ) - - function handleWorkspaceResourceSelect(resource: MothershipResource) { - const resolvedResource = resolveFileResource(resource) - const wasAdded = addResource(resolvedResource) + function openWorkspaceResource(resource: MothershipResource) { + const wasAdded = addResource(resource) if (!wasAdded) { - setActiveResourceId(resolvedResource.id) + setActiveResourceId(resource.id) } handleResourceEvent() } + /** + * Opens the resource a message chip points at, resolving it first. A chip may + * carry only a filename — the agent names a file before the client's file + * list knows it exists — so one forced refetch closes that window. What still + * resolves to nothing opens nothing, rather than a tab that cannot be + * viewed or removed. + */ + async function handleWorkspaceResourceSelect(ref: WorkspaceResourceRef) { + const immediate = resolveWorkspaceResourceRef(ref, workspaceFiles) + if (immediate) { + openWorkspaceResource(immediate) + return + } + if (ref.type !== 'file') return + + // `staleTime: 0` forces the fetch this branch exists for — the cached list + // is what already failed to resolve. `fetchQuery` rejects on error and this + // handler is invoked as a void callback, so failure becomes null rather + // than an unhandled rejection — and stays distinct from an empty list, so + // "we could not look" is never reported as "it is not there". + const files = await queryClient + .fetchQuery({ ...getWorkspaceFilesQueryOptions(workspaceId), staleTime: 0 }) + .catch(() => null) + const resolved = files && resolveWorkspaceResourceRef(ref, files) + if (resolved) { + openWorkspaceResource(resolved) + return + } + // The chip looks clickable, so refusing silently reads as a broken button. + toast.error( + files + ? `Couldn't find "${ref.title}" in this workspace` + : `Couldn't open "${ref.title}" — check your connection and try again` + ) + logger.warn('Ignored a resource chip that did not resolve', { + type: ref.type, + title: ref.title, + hasPath: Boolean(ref.path), + reachedWorkspace: files !== null, + }) + } + const hasMessages = messages.length > 0 const showChatSkeleton = Boolean(chatId) && !hasMessages && isChatHistoryPending const draftScopeKey = `${workspaceId}:${chatId ?? 'new'}` @@ -446,15 +500,16 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) const showEmptyState = !hasMessages && !showChatSkeleton return ( -
-
- {/* Clears the expand button when the panel is closed and that button is - occupying the same corner. */} +
+
{showEmptyState && (
@@ -464,10 +519,10 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
{/* Asymmetric padding biases the group up so the full cluster (heading + input + suggestions) sits at the optical center */}
-

+

What should we get done{firstName ? `, ${firstName}` : ''}?

-
+
- {isResourceCollapsed && ( -
+ {isDesktop ? ( +
+ ) : ( + isResourceCollapsed && ( +
+ +
+ ) )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts index 427d89f0642..995df519868 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/index.ts @@ -3,5 +3,4 @@ export { getWorkflowCopilotUseChatOptions, useChat, } from './use-chat' -export { useMothershipHandoff } from './use-mothership-handoff' export { useMothershipResize } from './use-mothership-resize' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx deleted file mode 100644 index d82b5e6dac9..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' -import { useMothershipHandoff } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff' - -const { mockQueryState } = vi.hoisted(() => ({ - mockQueryState: { - value: null as string | null, - setValue: vi.fn(), - }, -})) - -vi.mock('nuqs', () => ({ - useQueryState: () => [mockQueryState.value, mockQueryState.setValue], -})) - -const mockSendMessage = vi.fn(async () => {}) - -interface TestHarnessProps { - renderKey: number -} - -function TestHarness({ renderKey }: TestHarnessProps) { - useMothershipHandoff({ - workspaceId: 'workspace-1', - sendMessage: mockSendMessage, - }) - return {renderKey} -} - -describe('useMothershipHandoff', () => { - let container: HTMLDivElement - let root: Root - - beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - localStorage.clear() - mockQueryState.value = null - mockQueryState.setValue.mockClear() - mockSendMessage.mockClear() - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - }) - - afterEach(() => { - act(() => root.unmount()) - container.remove() - }) - - it('consumes a handoff on the initial Home mount', async () => { - MothershipHandoffStorage.store({ message: 'initial prompt' }, 'workspace-1') - - await act(async () => { - root.render() - }) - - expect(mockSendMessage).toHaveBeenCalledWith('initial prompt', undefined, undefined) - }) - - it('consumes a handoff when a cached Home route receives the URL signal', async () => { - await act(async () => { - root.render() - }) - MothershipHandoffStorage.store({ message: 'cached route prompt' }, 'workspace-1') - mockQueryState.value = '1' - - await act(async () => { - root.render() - }) - - expect(mockSendMessage).toHaveBeenCalledWith('cached route prompt', undefined, undefined) - expect(mockQueryState.setValue).toHaveBeenCalledWith(null) - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts deleted file mode 100644 index 423650905ba..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-handoff.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { useQueryState } from 'nuqs' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' -import type { UseChatReturn } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' -import { - mothershipHandoffParam, - mothershipHandoffUrlKeys, -} from '@/app/workspace/[workspaceId]/home/search-params' - -interface UseMothershipHandoffProps { - chatId?: string - workspaceId: string - sendMessage: UseChatReturn['sendMessage'] -} - -/** Consumes fresh-chat handoffs on first mount and cached-route reactivation. */ -export function useMothershipHandoff({ - chatId, - workspaceId, - sendMessage, -}: UseMothershipHandoffProps): void { - const [handoffSignal, setHandoffSignal] = useQueryState(mothershipHandoffParam.key, { - ...mothershipHandoffParam.parser, - ...mothershipHandoffUrlKeys, - }) - const hasCheckedInitialHandoffRef = useRef(false) - - useEffect(() => { - const shouldCheck = !hasCheckedInitialHandoffRef.current || Boolean(handoffSignal) - if (!shouldCheck) return - - hasCheckedInitialHandoffRef.current = true - if (handoffSignal) void setHandoffSignal(null) - if (chatId) return - - const handoff = MothershipHandoffStorage.consume(workspaceId) - if (handoff) void sendMessage(handoff.message, undefined, handoff.contexts) - }, [chatId, handoffSignal, sendMessage, setHandoffSignal, workspaceId]) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts index 26e67c9c5b5..ef850466f69 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/search-params.ts @@ -25,20 +25,3 @@ export const resourceUrlKeys = { history: 'replace', clearOnDefault: true, } as const - -/** Signals that a cached Home surface should consume a pending one-shot chat handoff. */ -export const mothershipHandoffParam = { - key: 'handoff', - parser: parseAsString, -} as const - -/** Removes the transient handoff signal without adding a browser-history entry. */ -export const mothershipHandoffUrlKeys = { - history: 'replace', - clearOnDefault: true, -} as const - -/** Builds a fresh-chat URL that wakes the handoff consumer without exposing prompt text. */ -export function getMothershipHandoffHref(workspaceId: string): string { - return `/workspace/${workspaceId}/home?${mothershipHandoffParam.key}=1` -} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 69807aedf3f..e01bee4bd83 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' import { Button, ChipConfirmModal, ChipDropdown, Plus, Tooltip, toast } from '@sim/emcn' -import { Database, FolderPlus } from '@sim/emcn/icons' +import { Database, FolderPlus, Pencil, Trash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' @@ -26,10 +26,14 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import type { + MoveOptionNode, + SortableResource, +} from '@/app/workspace/[workspaceId]/components/folders' import { buildDescendantIndex, buildMoveOptions, + FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, folderRow, @@ -37,6 +41,7 @@ import { nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + sortResources, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' @@ -53,7 +58,7 @@ import { knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' -import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' +import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -76,6 +81,11 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { docCount?: number } +/** A list row, resolved to the entity it refers to. */ +type KnowledgeResourceItem = + | { kind: 'base'; base: KnowledgeBaseWithDocCount } + | { kind: 'folder'; folder: WorkflowFolder } + const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, { id: 'documents', header: 'Documents', widthMultiplier: 0.6 }, @@ -102,8 +112,8 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' -const ROOT_BREADCRUMB_LABEL = 'Knowledge Base' const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const +const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { @@ -194,11 +204,17 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } = - useFolderNavigation({ - resourceType: FOLDER_RESOURCE_TYPE, - workspaceId, - }) + const { + currentFolderId, + setCurrentFolderId, + ancestors: breadcrumbs, + folders, + folderById, + foldersResolved, + } = useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -225,6 +241,8 @@ export function Knowledge() { const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) const { + sort: sortColumn, + dir: sortDirection, activeSort, onSort: onSortColumn, onClear: onClearSort, @@ -399,28 +417,10 @@ export function Knowledge() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) const needle = debouncedSearchQuery.trim().toLowerCase() - const searched = needle + return needle ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings - - const col = activeSort?.column ?? 'name' - const dir = activeSort?.direction ?? 'asc' - return [...searched].sort((a, b) => { - const aPinned = pinnedFolderIds.has(a.id) - const bPinned = pinnedFolderIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - if (col === 'created') { - cmp = a.createdAt.getTime() - b.createdAt.getTime() - } else if (col === 'updated') { - cmp = a.updatedAt.getTime() - b.updatedAt.getTime() - } else { - cmp = a.name.localeCompare(b.name) - } - return dir === 'asc' ? cmp : -cmp - }) - }, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds]) + }, [folders, currentFolderId, debouncedSearchQuery]) const processedKBs = useMemo(() => { /** @@ -461,45 +461,7 @@ export function Knowledge() { result = result.filter((kb) => ownerFilter.includes(kb.userId)) } - const col = activeSort?.column ?? 'updated' - const dir = activeSort?.direction ?? 'desc' - return [...result].sort((a, b) => { - // Pinned bases float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedBaseIds.has(a.id) - const bPinned = pinnedBaseIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - switch (col) { - case 'name': - cmp = a.name.localeCompare(b.name) - break - case 'documents': - cmp = - ((a as KnowledgeBaseWithDocCount).docCount || 0) - - ((b as KnowledgeBaseWithDocCount).docCount || 0) - break - case 'tokens': - cmp = (a.tokenCount || 0) - (b.tokenCount || 0) - break - case 'created': - cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'updated': - cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'connectors': - cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) - break - case 'owner': - cmp = (membersById.get(a.userId)?.name ?? '').localeCompare( - membersById.get(b.userId)?.name ?? '' - ) - break - } - return dir === 'asc' ? cmp : -cmp - }) + return result }, [ knowledgeBases, currentFolderId, @@ -509,52 +471,112 @@ export function Knowledge() { connectorFilter, contentFilter, ownerFilter, - activeSort, - membersById, - pinnedBaseIds, ]) - const baseRows: ResourceRow[] = useMemo(() => { - const folderRows = visibleFolders.map((folder) => - folderRow(folder, { + /** + * Folders and bases sort as ONE list — a folder never outranks a base it ties with, so a + * pinned base reaches the top of the list rather than the top of the base section. + * + * Decorate-sort: each row's key + pinned flag is computed ONCE (O(N)) so the comparator + * never re-runs Date parsing or member lookups per comparison. Folders carry no document, + * token, or connector count, so those keys are `null` and land the folders last in both + * directions — matching the em-dash they show in those cells. + */ + const sortedEntries = useMemo(() => { + const entries: SortableResource[] = [] + + for (const folder of visibleFolders) { + entries.push({ + item: { kind: 'folder', folder }, pinned: pinnedFolderIds.has(folder.id), - cells: { - documents: { label: EMPTY_CELL_PLACEHOLDER }, - tokens: { label: EMPTY_CELL_PLACEHOLDER }, - connectors: { label: EMPTY_CELL_PLACEHOLDER }, - created: timeCell(folder.createdAt), - owner: ownerCell(folder.userId, membersById), - updated: timeCell(folder.updatedAt), - }, + name: folder.name, + key: + sortColumn === 'documents' || sortColumn === 'tokens' || sortColumn === 'connectors' + ? null + : sortColumn === 'created' + ? new Date(folder.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(folder.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(folder.userId)?.name ?? null) + : folder.name, }) - ) + } - const knowledgeBaseRows = processedKBs.map((kb) => { - const kbWithCount = kb as KnowledgeBaseWithDocCount - return { - id: kb.id, - cells: { - name: { - icon: KNOWLEDGE_BASE_ICON, - label: kb.name, - pinned: pinnedBaseIds.has(kb.id), - }, - documents: { - label: String(kbWithCount.docCount || 0), - }, - tokens: { - label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', - }, - connectors: connectorCell(kb.connectorTypes), - created: timeCell(kb.createdAt), - owner: ownerCell(kb.userId, membersById), - updated: timeCell(kb.updatedAt), - }, - } - }) + for (const kb of processedKBs) { + entries.push({ + item: { kind: 'base', base: kb as KnowledgeBaseWithDocCount }, + pinned: pinnedBaseIds.has(kb.id), + name: kb.name, + key: + sortColumn === 'documents' + ? ((kb as KnowledgeBaseWithDocCount).docCount ?? 0) + : sortColumn === 'tokens' + ? (kb.tokenCount ?? 0) + : sortColumn === 'connectors' + ? (kb.connectorTypes?.length ?? 0) + : sortColumn === 'created' + ? new Date(kb.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(kb.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(kb.userId)?.name ?? null) + : kb.name, + }) + } + + return sortResources(entries, sortDirection) + }, [ + visibleFolders, + processedKBs, + sortColumn, + sortDirection, + membersById, + pinnedFolderIds, + pinnedBaseIds, + ]) - return [...folderRows, ...knowledgeBaseRows] - }, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds]) + const baseRows: ResourceRow[] = useMemo( + () => + sortedEntries.map(({ item, pinned }): ResourceRow => { + if (item.kind === 'folder') { + return folderRow(item.folder, { + pinned, + cells: { + documents: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + connectors: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(item.folder.createdAt), + owner: ownerCell(item.folder.userId, membersById), + updated: timeCell(item.folder.updatedAt), + }, + }) + } + + const { base } = item + return { + id: base.id, + cells: { + name: { + icon: KNOWLEDGE_BASE_ICON, + label: base.name, + pinned, + }, + documents: { + label: String(base.docCount || 0), + }, + tokens: { + label: base.tokenCount ? base.tokenCount.toLocaleString() : '0', + }, + connectors: connectorCell(base.connectorTypes), + created: timeCell(base.createdAt), + owner: ownerCell(base.userId, membersById), + updated: timeCell(base.updatedAt), + }, + } + }), + [sortedEntries, membersById] + ) /** * Rename is layered over the built rows rather than folded into the builder above, so a @@ -897,7 +919,7 @@ export function Knowledge() { () => folderBreadcrumbItems({ rootLabel: ROOT_BREADCRUMB_LABEL, - rootIcon: Database, + rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon, breadcrumbs, onNavigate: setCurrentFolderId, currentFolderEditing: @@ -916,6 +938,7 @@ export function Knowledge() { ? [ { label: 'Rename', + icon: Pencil, onClick: () => { const folder = breadcrumbs[breadcrumbs.length - 1] breadcrumbRenameRef.current.startRename(folder.id, folder.name) @@ -923,6 +946,7 @@ export function Knowledge() { }, { label: 'Delete', + icon: Trash, onClick: () => setFolderPendingDelete(breadcrumbs[breadcrumbs.length - 1]), }, ] @@ -957,8 +981,8 @@ export function Knowledge() { { id: 'tokens', label: 'Tokens' }, { id: 'connectors', label: 'Connectors' }, { id: 'created', label: 'Created' }, - { id: 'updated', label: 'Last Updated' }, { id: 'owner', label: 'Owner' }, + { id: 'updated', label: 'Last Updated' }, ], active: activeSort, onSort: onSortColumn, @@ -1010,7 +1034,6 @@ export function Knowledge() { onChange={(value) => setConnectorFilter(value === 'all' ? [] : [value])} align='start' fullWidth - flush />
@@ -1032,7 +1055,6 @@ export function Knowledge() { onChange={(value) => setContentFilter(value === 'all' ? [] : [value])} align='start' fullWidth - flush />
{memberOptions.length > 0 && ( @@ -1059,7 +1081,6 @@ export function Knowledge() { searchPlaceholder='Search members...' align='start' fullWidth - flush />
)} @@ -1098,7 +1119,7 @@ export function Knowledge() { <> () - const timezone = useTimezone() - const calendar = useCalendar(timezone) - - const range = useMemo( - () => visibleRange(calendar.scope, calendar.anchor), - [calendar.scope, calendar.anchor] - ) - const tasks = useScheduledTasks({ workspaceId, rangeStart: range.start, rangeEnd: range.end }) - - /** Pending tasks open the editable TaskModal; running/finished open the record. */ - const editTask = tasks.selectedTask?.status === 'pending' ? tasks.selectedTask : null - const recordTask = tasks.selectedTask?.status !== 'pending' ? tasks.selectedTask : null - const editSeed = editTask ? tasks.editSeedFor(editTask) : null - - const { - isOpen: isListContextMenuOpen, - position: listContextMenuPosition, - handleContextMenu: handleListContextMenu, - closeMenu: closeListContextMenu, - } = useContextMenu() - - const { - isOpen: isTaskContextMenuOpen, - position: taskContextMenuPosition, - handleContextMenu: handleTaskCtxMenu, - closeMenu: closeTaskContextMenu, - } = useContextMenu() - - /** The right-clicked task — drives the context menu items. */ - const [contextTask, setContextTask] = useState(null) - /** The task targeted for deletion — drives the (recurring-aware) delete dialog. */ - const [deletingTask, setDeletingTask] = useState(null) - /** Pre-fill for a duplicate — opens the create modal seeded from an existing task. */ - const [duplicatePrefill, setDuplicatePrefill] = useState(null) - - /** Starts a blank create. The three modal sources are mutually exclusive, so it closes the others. */ - const handleOpenCreate = useCallback(() => { - setDuplicatePrefill(null) - tasks.closeTask() - calendar.openCreate() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [calendar.openCreate]) - - useRegisterGlobalCommands(() => [ - { id: 'scheduled-tasks-new', handler: () => handleOpenCreate() }, - ]) - - /** Starts a slot-seeded create, closing any other open modal. */ - const handleSelectSlot = useCallback( - (date: Date, time?: string) => { - setDuplicatePrefill(null) - tasks.closeTask() - calendar.selectSlot(date, time) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [calendar.selectSlot] - ) - - /** Opens a task's edit/record modal, closing any create/duplicate flow. */ - const handleOpenTask = useCallback( - (task: ScheduledTask) => { - setDuplicatePrefill(null) - calendar.closeCreate() - tasks.openTask(task) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [calendar.closeCreate] - ) - - const handleDuplicate = useCallback(() => { - if (!contextTask) return - const seed = tasks.editSeedFor(contextTask) - if (!seed) return - const { scheduleId: _scheduleId, ...prefill } = seed - calendar.closeCreate() - tasks.closeTask() - setDuplicatePrefill(prefill) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contextTask, calendar.closeCreate]) - - const handleTaskContextMenu = useCallback( - (task: ScheduledTask, e: React.MouseEvent) => { - closeListContextMenu() - setContextTask(task) - handleTaskCtxMenu(e) - }, - [closeListContextMenu, handleTaskCtxMenu] - ) - - /** Opens the right-clicked task's modal (edit for pending, record otherwise). */ - const openContextTask = useCallback(() => { - if (contextTask) handleOpenTask(contextTask) - }, [contextTask, handleOpenTask]) - - const handlePauseContextTask = useCallback(() => { - if (contextTask) tasks.pauseTask(contextTask.scheduleId) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contextTask]) - - const handleResumeContextTask = useCallback(() => { - if (contextTask) tasks.resumeTask(contextTask.scheduleId) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [contextTask]) - - const handleContentContextMenu = useCallback( - (e: React.MouseEvent) => { - const target = e.target as HTMLElement - if ( - target.closest('[data-resource-row]') || - target.closest('button, input, a, [role="button"]') - ) { - return - } - handleListContextMenu(e) - }, - [handleListContextMenu] - ) - - const headerActions: ResourceAction[] = useMemo( - () => [ - { - text: 'New scheduled task', - icon: Plus, - onSelect: handleOpenCreate, - variant: 'primary', - }, - ], - [handleOpenCreate] - ) - - return ( - <> - - - - - - - - setDeletingTask(contextTask)} - /> - - setDeletingTask(null)} - onDeleteOccurrence={(task) => tasks.deleteOccurrence(task.scheduleId, task.runAt)} - onDeleteSeries={(task) => tasks.deleteTask(task.scheduleId)} - /> - - { - if (!open) { - calendar.closeCreate() - setDuplicatePrefill(null) - } - }} - slot={duplicatePrefill ? null : calendar.selectedSlot} - prefill={duplicatePrefill} - onSubmit={tasks.createTask} - /> - - { - if (!open) tasks.closeTask() - }} - edit={editSeed} - onSubmit={(draft) => { - if (editTask) return tasks.updateTask(editTask.scheduleId, draft) - }} - onRequestDelete={() => { - setDeletingTask(editTask) - tasks.closeTask() - }} - /> - - - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 6f08b40d523..8aedd590cf9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -7,8 +7,8 @@ import { type ReactNode, } from 'react' import { cn } from '@sim/emcn' +import { Search } from '@sim/emcn/icons' import { Command } from 'cmdk' -import { Search } from 'lucide-react' type CommandInputProps = ComponentPropsWithoutRef type CommandListProps = ComponentPropsWithoutRef diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index 2fef47d2ee4..3788da7d7ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -2,9 +2,9 @@ import type { ComponentType } from 'react' import { memo } from 'react' -import { ChipTag, cn } from '@sim/emcn' +import { cn } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' -import { getMappedWorkflowTypeAccent } from '@sim/workflow-renderer' +import { WorkflowTypeIcon } from '@sim/workflow-renderer' import { Command } from 'cmdk' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' @@ -95,18 +95,10 @@ export const MemoizedCommandItem = memo( labelPrefix, meta, }: CommandItemProps) { - const workflowAccent = workflowType ? getMappedWorkflowTypeAccent(workflowType) : null - return ( - {workflowAccent ? ( - - - + {workflowType ? ( + ) : (
- + {name} {meta ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index c37b00ea53b..825a6f6ece7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -8,15 +8,15 @@ import { useRef, useState, } from 'react' -import { cn, Library } from '@sim/emcn' +import { cn, Library, useNativeSurfaceOcclusionReady } from '@sim/emcn' import { - Calendar, Columns3, Database, Download, Duplicate, File, FolderPlus, + Hammer, HelpCircle, Home, Integration, @@ -26,6 +26,7 @@ import { Plus, RefreshCw, Rocket, + SelectAll, Send, Settings, Table, @@ -35,10 +36,10 @@ import { } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { Command } from 'cmdk' -import { Scan } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' +import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' @@ -138,6 +139,9 @@ function SearchModalContent({ const currentWorkflowId = params.workflowId as string | undefined const inputRef = useRef(null) const listRef = useRef(null) + const atomicBrowserOcclusion = supportsAtomicBrowserPanelOcclusion() + const nativeSurfaceReady = useNativeSurfaceOcclusionReady(true, 'modal') + const visuallyOpen = nativeSurfaceReady const { navigateToSettings } = useSettingsNavigation() const { config: permissionConfig } = usePermissionConfig() const invokeCommand = useInvokeGlobalCommand() @@ -166,6 +170,13 @@ function SearchModalContent({ href: `/workspace/${workspaceId}/integrations`, hidden: permissionConfig.hideIntegrationsTab, }, + { + id: 'skills', + name: 'Skills', + icon: Hammer, + href: `/workspace/${workspaceId}/skills`, + hidden: permissionConfig.hideIntegrationsTab, + }, { id: 'tables', name: 'Tables', @@ -182,17 +193,11 @@ function SearchModalContent({ }, { id: 'knowledge-base', - name: 'Knowledge base', + name: 'Knowledge bases', icon: Database, href: `/workspace/${workspaceId}/knowledge`, hidden: permissionConfig.hideKnowledgeBaseTab, }, - { - id: 'scheduled-tasks', - name: 'Scheduled tasks', - icon: Calendar, - href: `/workspace/${workspaceId}/scheduled-tasks`, - }, { id: 'logs', name: 'Logs', @@ -264,7 +269,7 @@ function SearchModalContent({ id: 'fit-to-view', name: 'Fit workflow to view', keywords: 'zoom center recenter canvas reset', - icon: Scan, + icon: SelectAll, shortcut: '⇧⌘F', context: 'workflow', run: invoke('fit-to-view'), @@ -558,16 +563,6 @@ function SearchModalContent({ } ) } - if (canEdit && pageContext === 'scheduledTasks') { - list.push({ - id: 'scheduled-tasks-new', - name: 'New scheduled task', - keywords: 'create add schedule cron recurring', - icon: Plus, - context: 'scheduledTasks', - run: invoke('scheduled-tasks-new'), - }) - } return list }, [ workspaceId, @@ -587,6 +582,16 @@ function SearchModalContent({ const searchRef = useRef(search) searchRef.current = search + /** + * Focus once the dialog is actually visible: under atomic browser-panel + * occlusion `autoFocus` is suppressed, and `.focus()` is a no-op while the + * surface still carries `invisible`. + */ + useEffect(() => { + if (!visuallyOpen) return + inputRef.current?.focus() + }, [visuallyOpen]) + const handleSearchChange = useCallback((value: string) => { searchRef.current = value setSearch(value) @@ -1152,15 +1157,24 @@ function SearchModalContent({ return createPortal( <>
Date: Mon, 10 Aug 2026 19:53:20 -0700 Subject: [PATCH 19/29] feat(search): auto-send Ask Sim queries via the chat handoff --- .../search-modal/search-modal.test.tsx | 20 +++++++------------ .../components/search-modal/search-modal.tsx | 14 ++++++------- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index dd51ea6d69a..5c09939a3fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -42,15 +42,6 @@ vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn(), })) -/** - * The real implementation runs the integration matcher over the block - * registry (globally mocked in jsdom); the palette only needs the store - * side effect, so delegate straight to LandingPromptStorage. - */ -vi.mock('@/blocks/integration-matcher', () => ({ - storeCuratedPrompt: (prompt: string) => LandingPromptStorage.store(prompt), -})) - vi.mock('@/app/workspace/[workspaceId]/providers/global-commands-provider', () => ({ useInvokeGlobalCommand: () => vi.fn(), })) @@ -172,7 +163,10 @@ describe('SearchModal', () => { expect(onOpenChange).toHaveBeenCalledWith(false) expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') - expect(LandingPromptStorage.consume()).toBe('plan our Slack launch week') + expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ + message: 'plan our Slack launch week', + contexts: [], + }) }) it('returns to search results when Tab is pressed again in ask mode', async () => { @@ -304,7 +298,7 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(LandingPromptStorage.consume()).toBeNull() + expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } @@ -700,7 +694,7 @@ describe('SearchModal', () => { it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) try { await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 825a6f6ece7..75c1ed06bfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -41,6 +41,7 @@ import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' @@ -84,7 +85,6 @@ import { CMDK_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar' -import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useSearchModalStore } from '@/stores/modals/search/store' @@ -844,12 +844,12 @@ function SearchModalContent({ const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - /* Prefill (not auto-send) via the same seam as the integrations "Explore" - showcase: seed the chat input on the freshly mounted home surface. The - MothershipHandoffStorage auto-send path drops sends started during - Home's mount-settling window (use-chat's cleanup abort), so the message - would silently vanish. */ - if (!storeCuratedPrompt(query)) { + /* One-shot auto-send handoff: Home's mount consumer sends it on arrival, + so both routes deliver the raw query identically. use-chat's queued + send dispatch now survives the mount-settling effect cycle that used + to silently abort programmatic sends (the old reason this was a + prefill). */ + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) From 08128f1f8f35b74a640edcec2c421e6143038c12 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:58:28 -0700 Subject: [PATCH 20/29] revert(search): return Ask Sim to prefill, storing raw prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-send still loses the message on cross-route navigation — use-chat's cleanup abort fires during Home's mount-settling effect cycle. Prefill restored, but via LandingPromptStorage directly so free-form queries are never mentionified into @ chips. --- .../components/search-modal/search-modal.test.tsx | 11 ++++------- .../components/search-modal/search-modal.tsx | 13 ++++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 5c09939a3fd..fc123628035 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -163,10 +163,7 @@ describe('SearchModal', () => { expect(onOpenChange).toHaveBeenCalledWith(false) expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') - expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ - message: 'plan our Slack launch week', - contexts: [], - }) + expect(LandingPromptStorage.consume()).toBe('plan our Slack launch week') }) it('returns to search results when Tab is pressed again in ask mode', async () => { @@ -298,7 +295,7 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() + expect(LandingPromptStorage.consume()).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } @@ -694,7 +691,7 @@ describe('SearchModal', () => { it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) try { await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 75c1ed06bfa..b22262b1215 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -41,7 +41,7 @@ import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' @@ -844,12 +844,11 @@ function SearchModalContent({ const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - /* One-shot auto-send handoff: Home's mount consumer sends it on arrival, - so both routes deliver the raw query identically. use-chat's queued - send dispatch now survives the mount-settling effect cycle that used - to silently abort programmatic sends (the old reason this was a - prefill). */ - if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { + /* Prefill, not auto-send: sends started during Home's mount-settling + window are still silently aborted by use-chat's cleanup effect, so a + MothershipHandoffStorage handoff would vanish (verified live). Stored + raw — user prose is never mentionified into @ chips. */ + if (!LandingPromptStorage.store(query)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) From a77ce2a2e1940fc5bd42e9fcb1c7ed0272509532 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:13:51 -0700 Subject: [PATCH 21/29] feat(search): auto-send Ask Sim queries via the chat handoff Re-lands the auto-send flip: with fix/mship-mount-send-loss beneath this branch, sends started during Home's mount-settling window survive the cleanup abort (queued, restored, re-dispatched), so the handoff no longer loses the query on cross-route navigation. --- .../components/search-modal/search-modal.test.tsx | 11 +++++++---- .../components/search-modal/search-modal.tsx | 13 +++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index fc123628035..5c09939a3fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -4,7 +4,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { MOTHERSHIP_SEND_MESSAGE_EVENT, type MothershipSendMessageDetail, @@ -163,7 +163,10 @@ describe('SearchModal', () => { expect(onOpenChange).toHaveBeenCalledWith(false) expect(mockPush).toHaveBeenCalledWith('/workspace/workspace-1/home') - expect(LandingPromptStorage.consume()).toBe('plan our Slack launch week') + expect(MothershipHandoffStorage.consume('workspace-1')).toEqual({ + message: 'plan our Slack launch week', + contexts: [], + }) }) it('returns to search results when Tab is pressed again in ask mode', async () => { @@ -295,7 +298,7 @@ describe('SearchModal', () => { expect(receivedMessages).toEqual(['summarize this workspace']) expect(mockPush).not.toHaveBeenCalled() - expect(LandingPromptStorage.consume()).toBeNull() + expect(MothershipHandoffStorage.consume('workspace-1')).toBeNull() } finally { window.removeEventListener(MOTHERSHIP_SEND_MESSAGE_EVENT, handleMessage) } @@ -691,7 +694,7 @@ describe('SearchModal', () => { it('keeps the palette open when the query handoff cannot be persisted', async () => { const onOpenChange = vi.fn() - const storeSpy = vi.spyOn(LandingPromptStorage, 'store').mockReturnValue(false) + const storeSpy = vi.spyOn(MothershipHandoffStorage, 'store').mockReturnValue(false) try { await act(async () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index b22262b1215..75c1ed06bfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -41,7 +41,7 @@ import { usePostHog } from 'posthog-js/react' import { createPortal } from 'react-dom' import { supportsAtomicBrowserPanelOcclusion } from '@/lib/browser-agent/transport' import { isChatEnabled } from '@/lib/core/config/env-flags' -import { LandingPromptStorage } from '@/lib/core/utils/browser-storage' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { sendMothershipMessage } from '@/lib/mothership/events' import { captureEvent } from '@/lib/posthog/client' import { toSearchToken } from '@/lib/search/tokens' @@ -844,11 +844,12 @@ function SearchModalContent({ const sentToMountedHome = window.location.pathname === homeHref && sendMothershipMessage(query) if (!sentToMountedHome) { - /* Prefill, not auto-send: sends started during Home's mount-settling - window are still silently aborted by use-chat's cleanup effect, so a - MothershipHandoffStorage handoff would vanish (verified live). Stored - raw — user prose is never mentionified into @ chips. */ - if (!LandingPromptStorage.store(query)) { + /* One-shot auto-send handoff: Home's mount consumer sends it on arrival, + so both routes deliver the raw query identically. use-chat's queued + send dispatch now survives the mount-settling effect cycle that used + to silently abort programmatic sends (the old reason this was a + prefill). */ + if (!MothershipHandoffStorage.store({ message: query }, workspaceId)) { logger.warn('Failed to persist command palette query for a new chat', { workspaceId, }) From 62971be1492e7806a48a5810078df1aab1ab6513 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:21:02 -0700 Subject: [PATCH 22/29] fix(deploy): include registry loading in the deploy invoker gate --- .../[workflowId]/components/panel/components/deploy/deploy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index e3b0fcbe100..0a92b0a63e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -63,7 +63,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: (!isDeployed && deployReadiness.isBlocked && !deployReadiness.isSyncing) const onDeployClick = async () => { - if (isDisabled || !activeWorkflowId) return + if (isRegistryLoading || isDisabled || !activeWorkflowId) return if (isDeploymentSettling) { setIsModalOpen(true) From 5a8ac0d4b5cca03c703b2f1482fcf560b5cd43e0 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:45:55 -0700 Subject: [PATCH 23/29] improvement(search): label the ask row New Chat --- .../sidebar/components/search-modal/search-modal.test.tsx | 4 ++-- .../sidebar/components/search-modal/search-modal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index 5c09939a3fd..bcfa763cbe8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -150,7 +150,7 @@ describe('SearchModal', () => { const askRow = document.querySelector('[cmdk-item]') expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1) - expect(askRow?.textContent).toBe('Ask Sim: plan our Slack launch week') + expect(askRow?.textContent).toBe('New Chat: plan our Slack launch week') expect(askRow?.getAttribute('aria-selected')).toBe('true') act(() => { @@ -193,7 +193,7 @@ describe('SearchModal', () => { new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }) ) }) - expect(document.querySelector('[cmdk-item]')?.textContent).toBe('Ask Sim: rainbow') + expect(document.querySelector('[cmdk-item]')?.textContent).toBe('New Chat: rainbow') act(() => { document diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 75c1ed06bfa..8c92c60c518 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1068,7 +1068,7 @@ function SearchModalContent({ : [], [orderedSections, entriesBySection, isSearching] ) - const askSimLabel = searchQuery ? `Ask Sim: ${searchQuery}` : 'Start a new chat' + const askSimLabel = searchQuery ? `New Chat: ${searchQuery}` : 'Start a new chat' const sectionGroups = useMemo(() => { const actionEntriesByLabel = (label: ActionGroupLabel) => entriesBySection.actions.filter( From c4b272806463257289791779ece938643e313944 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:02:52 -0700 Subject: [PATCH 24/29] refactor(search): apply simplify-pass cleanups - rank against a deferred query and hoist search-independent derivations so typing never blocks on the cross-section re-rank - cache secondary-text tokenization (hottest per-keystroke loop) - skip building browse groups mid-search; gate the palette-only credentials and logs queries on the palette being open - flatten getGlobalSearchResults onto a spec-stable sort - drop the dead store-side SEARCH_SECTIONS/docs path, the no-op CommandSearch surface variant, a duplicate hex regex, a dead font-base class, and the TaskItem/FolderedItem shape overlap --- .../connection-block-selector.tsx | 3 +- .../command-chrome/command-chrome.test.tsx | 4 +- .../command-chrome/command-chrome.tsx | 16 +-- .../command-items/command-items.tsx | 15 +- .../search-modal/search-modal.test.tsx | 1 - .../components/search-modal/search-modal.tsx | 134 ++++++++++-------- .../sidebar/components/search-modal/utils.ts | 64 ++++----- .../w/components/sidebar/sidebar.tsx | 9 +- apps/sim/stores/modals/search/store.test.ts | 1 - apps/sim/stores/modals/search/store.ts | 13 -- apps/sim/stores/modals/search/types.ts | 38 ----- 11 files changed, 124 insertions(+), 174 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 67bc108a039..77b7deb77a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -2,7 +2,7 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn } from '@sim/emcn' -import { Search, X } from '@sim/emcn/icons' +import { X } from '@sim/emcn/icons' import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer' import { Command } from 'cmdk' import { useParams } from 'next/navigation' @@ -490,7 +490,6 @@ export function ConnectionBlockSelector({ id, data }: NodeProps { root.render( - + ) }) @@ -67,7 +67,7 @@ describe('CommandFadedList', () => { act(() => { root.render( - + First Second diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 8aedd590cf9..ae1aa51f660 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -14,7 +14,6 @@ type CommandInputProps = ComponentPropsWithoutRef type CommandListProps = ComponentPropsWithoutRef interface CommandSearchProps extends Omit { - surface: 'canvas' | 'palette' cycleResultsOnTab?: boolean /** Trailing slot after the input (e.g. a mode hint). Non-interactive. */ endAdornment?: ReactNode @@ -24,12 +23,8 @@ interface CommandFadedListProps extends CommandListProps { fade: 'canvas' | 'palette' } -const SEARCH_SURFACE_CLASSNAME = { - canvas: - 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', - palette: - 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', -} as const +const SEARCH_SURFACE_CLASSNAME = + 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]' /** * The palette hides its scrollbar (`scrollbar-none` at the call site), so it @@ -46,10 +41,7 @@ const LIST_FADE_CLASSNAME = { /** Borderless search field layered over a fading command-result list. */ export const CommandSearch = forwardRef( - function CommandSearch( - { surface, cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, - ref - ) { + function CommandSearch({ cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, ref) { const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event) if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return @@ -68,7 +60,7 @@ export const CommandSearch = forwardRef(
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index 3788da7d7ac..5e53d16e56d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -6,6 +6,7 @@ import { cn } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' import { WorkflowTypeIcon } from '@sim/workflow-renderer' import { Command } from 'cmdk' +import { HEX_COLOR_REGEX } from '@/lib/branding' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { getTileIconColorClass } from '@/blocks/icon-color' @@ -26,15 +27,12 @@ function ItemMeta({ meta }: ItemMetaProps) { interface ItemFolderPathProps { folderPath: string[] - className?: string } /** Trailing folder-path receipt whose head segments yield space to the leaf. */ -function ItemFolderPath({ folderPath, className }: ItemFolderPathProps) { +function ItemFolderPath({ folderPath }: ItemFolderPathProps) { return ( - + {folderPath.length > 1 && ( <> @@ -60,8 +58,6 @@ interface ShortcutHintProps { shortcut: string } -const WORKSPACE_COLOR_REGEX = /^#[\da-f]{6}$/i - function ShortcutHint({ shortcut }: ShortcutHintProps) { const commandIndex = shortcut.indexOf('⌘') const slots = @@ -228,7 +224,7 @@ export const MemoizedFileItem = memo( {meta ? ( ) : folderPath && folderPath.length > 0 ? ( - + ) : null} ) @@ -278,8 +274,7 @@ export const MemoizedWorkspaceItem = memo( logoUrl?: string | null color?: string } & ResultMetaProps) { - const backgroundColor = - color && WORKSPACE_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)' + const backgroundColor = color && HEX_COLOR_REGEX.test(color) ? color : 'var(--brand-accent)' return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index bcfa763cbe8..d10ca918fe4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -19,7 +19,6 @@ const { mockPush, mockSearchState } = vi.hoisted(() => ({ tools: [] as unknown[], triggers: [] as unknown[], toolOperations: [] as unknown[], - docs: [] as unknown[], isInitialized: true, }, }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 8c92c60c518..2f8e4a8d850 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -3,6 +3,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, useCallback, + useDeferredValue, useEffect, useMemo, useRef, @@ -102,7 +103,7 @@ const MAX_SEARCH_RESULTS = 50 export type { SearchModalProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' -interface SearchModalContentProps extends Omit {} +type SearchModalContentProps = Omit export function SearchModal({ open, ...props }: SearchModalProps) { const [mounted, setMounted] = useState(false) @@ -577,6 +578,12 @@ function SearchModalContent({ ]) const [search, setSearch] = useState('') + /** + * Ranking runs against the deferred query: the full cross-section re-rank + * (1000+ rows on the canvas) would otherwise block every keystroke's + * commit. Handlers keep reading the live value via `searchRef`. + */ + const deferredSearch = useDeferredValue(search) /** Tab-toggled ask mode: Enter hands the typed query to Sim as a new chat. */ const [askMode, setAskMode] = useState(false) const searchRef = useRef(search) @@ -593,7 +600,6 @@ function SearchModalContent({ }, [visuallyOpen]) const handleSearchChange = useCallback((value: string) => { - searchRef.current = value setSearch(value) requestAnimationFrame(() => { if (listRef.current) listRef.current.scrollTop = 0 @@ -887,8 +893,51 @@ function SearchModalContent({ onOpenChangeRef.current(false) }, []) + const onCanvas = pageContext === 'workflow' + const actionsByGroup = useMemo(() => { + const available = actions.filter( + (action) => action.context === 'global' || action.context === pageContext + ) + return { + page: pageContext + ? available.filter((action) => getActionGroupLabel(action) === 'Actions') + : [], + sim: available.filter((action) => getActionGroupLabel(action) === 'Sim'), + } + }, [actions, pageContext]) + const availableBlocks = useMemo( + () => + onCanvas + ? blocks.filter( + (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId + ) + : [], + [onCanvas, blocks, currentWorkflowId] + ) + const availableTools = useMemo( + () => + onCanvas + ? tools.filter( + (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId + ) + : [], + [onCanvas, tools, currentWorkflowId] + ) + /** Palette triggers carry a display suffix; `baseName` keeps the true name rankable. */ + const displayTriggers = useMemo( + () => + onCanvas + ? triggers.map((trigger) => ({ + ...trigger, + baseName: trigger.name, + name: trigger.name.endsWith('Trigger') ? trigger.name : `${trigger.name} Trigger`, + })) + : [], + [onCanvas, triggers] + ) + const entriesBySection = useMemo((): Record => { - const query = search.trim() + const query = deferredSearch.trim() const rank = ( section: SearchSection, items: T[], @@ -896,41 +945,17 @@ function SearchModalContent({ toExtra?: (item: T) => string | undefined ) => query - ? scoreSectionItems(section, items, toValue, search, toExtra, MAX_RESULTS_PER_GROUP) + ? scoreSectionItems(section, items, toValue, deferredSearch, toExtra, MAX_RESULTS_PER_GROUP) : items.map((item) => ({ item, score: 0 })) - const availableActions = actions.filter( - (action) => action.context === 'global' || action.context === pageContext - ) const rankActionGroup = (items: ActionItem[], groupLabel: ActionGroupLabel) => query - ? scoreActions(items, search, MAX_RESULTS_PER_GROUP, groupLabel) + ? scoreActions(items, deferredSearch, MAX_RESULTS_PER_GROUP, groupLabel) : items.map((item) => ({ item, score: 0 })) - const pageGroupLabel = pageContext ? ('Actions' as const) : null const rankedActions = [ - ...(pageGroupLabel - ? rankActionGroup( - availableActions.filter((action) => getActionGroupLabel(action) === pageGroupLabel), - pageGroupLabel - ) - : []), - ...rankActionGroup( - availableActions.filter((action) => getActionGroupLabel(action) === 'Sim'), - 'Sim' - ), + ...(pageContext ? rankActionGroup(actionsByGroup.page, 'Actions') : []), + ...rankActionGroup(actionsByGroup.sim, 'Sim'), ] - const onCanvas = pageContext === 'workflow' - const availableBlocks = onCanvas - ? blocks.filter( - (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId - ) - : [] - const availableTools = onCanvas - ? tools.filter( - (tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId - ) - : [] - return { actions: rankedActions.map(({ item, score }) => ({ section: 'actions', item, score })), blocks: rank( @@ -941,13 +966,7 @@ function SearchModalContent({ ).map(({ item, score }) => ({ section: 'blocks', item, score })), triggers: rank( 'triggers', - onCanvas - ? triggers.map((trigger) => ({ - ...trigger, - baseName: trigger.name, - name: trigger.name.endsWith('Trigger') ? trigger.name : `${trigger.name} Trigger`, - })) - : [], + displayTriggers, (item) => item.name, (item) => `${toSearchToken(item.name)} ${item.id}` ).map(({ item, score }) => ({ @@ -1025,14 +1044,14 @@ function SearchModalContent({ })), } }, [ - search, - actions, + deferredSearch, + actionsByGroup, pageContext, - blocks, - tools, - triggers, + onCanvas, + availableBlocks, + availableTools, + displayTriggers, toolOperations, - currentWorkflowId, integrations, connectedAccounts, chats, @@ -1052,15 +1071,15 @@ function SearchModalContent({ * page's own entity section is hoisted directly under `actions`, the rest * keep the canonical order. */ + const hoistedSection = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined const orderedSections = useMemo((): SearchSection[] => { - const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined - if (!hoisted) return [...SEARCH_SECTIONS] + if (!hoistedSection) return [...SEARCH_SECTIONS] return [ 'actions', - hoisted, - ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoisted), + hoistedSection, + ...SEARCH_SECTIONS.filter((section) => section !== 'actions' && section !== hoistedSection), ] - }, [pageContext]) + }, [hoistedSection]) const searchResults = useMemo( () => isSearching @@ -1070,6 +1089,7 @@ function SearchModalContent({ ) const askSimLabel = searchQuery ? `New Chat: ${searchQuery}` : 'Start a new chat' const sectionGroups = useMemo(() => { + if (isSearching) return [] const actionEntriesByLabel = (label: ActionGroupLabel) => entriesBySection.actions.filter( (entry) => entry.section === 'actions' && getActionGroupLabel(entry.item) === label @@ -1079,17 +1099,15 @@ function SearchModalContent({ heading: SECTION_LABELS[section], entries: entriesBySection[section], }) - const pageGroupLabel = pageContext ? ('Actions' as const) : null - const hoisted = pageContext ? PAGE_CONTEXT_HOISTED_SECTION[pageContext] : undefined const canvasSections = new Set(CANVAS_SECTIONS) const groups = [ - ...(pageGroupLabel + ...(pageContext ? [ { key: 'page-actions', - heading: pageGroupLabel, - entries: actionEntriesByLabel(pageGroupLabel), + heading: 'Actions', + entries: actionEntriesByLabel('Actions'), }, ] : []), @@ -1098,10 +1116,11 @@ function SearchModalContent({ heading: 'Sim', entries: actionEntriesByLabel('Sim'), }, - ...(hoisted ? [entityGroup(hoisted)] : []), + ...(hoistedSection ? [entityGroup(hoistedSection)] : []), ...CANVAS_SECTIONS.map(entityGroup), ...SEARCH_SECTIONS.filter( - (section) => section !== 'actions' && section !== hoisted && !canvasSections.has(section) + (section) => + section !== 'actions' && section !== hoistedSection && !canvasSections.has(section) ).map(entityGroup), ] @@ -1115,7 +1134,7 @@ function SearchModalContent({ remaining = 0 return truncated }) - }, [entriesBySection, pageContext]) + }, [entriesBySection, pageContext, hoistedSection, isSearching]) const entryHandlers = useMemo( (): SearchEntryHandlers => ({ @@ -1230,7 +1249,6 @@ function SearchModalContent({ void onSelectChat: (item: TaskItem) => void onSelectWorkflow: (item: WorkflowItem) => void - onSelectTable: (item: TaskItem) => void + onSelectTable: (item: FolderedItem) => void onSelectFile: (item: FileItem) => void - onSelectKnowledgeBase: (item: TaskItem) => void + onSelectKnowledgeBase: (item: FolderedItem) => void onSelectLog: (item: LogItem) => void onSelectWorkspace: (item: WorkspaceItem) => void onSelectPage: (item: PageItem) => void @@ -256,28 +255,11 @@ export function getGlobalSearchResults( entriesBySection: Partial>, sections: readonly SearchSection[] ): SearchEntry[] { - const sectionOrder = new Map(sections.map((section, index) => [section, index])) - const rankedMatches: Array<{ entry: SearchEntry; originalIndex: number }> = [] - let originalIndex = 0 - - const compare = ( - a: { entry: SearchEntry; originalIndex: number }, - b: { entry: SearchEntry; originalIndex: number } - ) => - b.entry.score - a.entry.score || - (sectionOrder.get(a.entry.section) ?? sections.length) - - (sectionOrder.get(b.entry.section) ?? sections.length) || - a.originalIndex - b.originalIndex - - for (const section of sections) { - for (const entry of entriesBySection[section] ?? []) { - rankedMatches.push({ entry, originalIndex }) - originalIndex += 1 - } - } - - rankedMatches.sort(compare) - return rankedMatches.map(({ entry }) => entry) + /* Flattening in section order makes the spec-stable sort's tie-break the + section order (then within-section order) with no explicit comparator. */ + return sections + .flatMap((section) => entriesBySection[section] ?? []) + .sort((a, b) => b.score - a.score) } /** @@ -462,12 +444,6 @@ const SECTION_MATCH_TIER = 2_000_000 */ export const PAGE_MATCH_TIER = 3_000_000 -/** - * Ranks an item by its name first, falling back to secondary text (ids, aliases, - * option labels) only when the name doesn't match — a name match always wins, so - * an exact name hit isn't diluted by a long secondary string ("Agent" beats - * "Pi Coding Agent" for the query "agent"). - */ /** * Matches a query against secondary search text: a space-separated list of * entries where multi-word phrases are kebab-cased into single tokens (see @@ -477,16 +453,34 @@ export const PAGE_MATCH_TIER = 3_000_000 * "send-message") but never assemble itself across unrelated entries * ("whatsapp" must not match "wealthbox-write-contact match snap up"). */ +/** + * Secondary-text strings are stable catalog data (block/tool/operation search + * values), so their word splits are cached — the palette re-matches every + * miss on every keystroke, and re-splitting dominated that loop. + */ +const secondaryTextWords = new Map() + function matchSecondaryText(extra: string, query: string): FuzzyResult { const whole = fuzzyMatch(extra, query, { scatter: false }) let best = whole.matched ? whole : NO_MATCH - for (const word of extra.split(/\s+/)) { + let words = secondaryTextWords.get(extra) + if (!words) { + words = extra.split(/\s+/) + secondaryTextWords.set(extra, words) + } + for (const word of words) { const byWord = fuzzyMatch(word, query) if (byWord.matched && (!best.matched || byWord.score > best.score)) best = byWord } return best } +/** + * Ranks an item by its name first, falling back to secondary text (ids, aliases, + * option labels) only when the name doesn't match — a name match always wins, so + * an exact name hit isn't diluted by a long secondary string ("Agent" beats + * "Pi Coding Agent" for the query "agent"). + */ function scoreItem(name: string, search: string, getExtra?: () => string | undefined): FuzzyResult { const byName = fuzzyMatch(name, search) if (byName.matched) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index b614a689d47..b5aa4e82855 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -1137,12 +1137,17 @@ export const Sidebar = memo(function Sidebar({ const { data: fetchedCredentials = [] } = useWorkspaceCredentials({ workspaceId, - enabled: !permissionConfig.hideIntegrationsTab && searchModalPageContext !== 'workflow', + enabled: + isSearchModalOpen && + !permissionConfig.hideIntegrationsTab && + searchModalPageContext !== 'workflow', }) const isOnLogsPage = searchModalPageContext === 'logs' || searchModalPageContext === 'logsDashboard' - const logsPages = useLogsList(workspaceId, SEARCH_MODAL_LOG_FILTERS, { enabled: isOnLogsPage }) + const logsPages = useLogsList(workspaceId, SEARCH_MODAL_LOG_FILTERS, { + enabled: isSearchModalOpen && isOnLogsPage, + }) const searchModalLogs = useMemo((): LogItem[] => { const rows = logsPages.data?.pages[0]?.logs ?? [] return rows.map((log) => ({ diff --git a/apps/sim/stores/modals/search/store.test.ts b/apps/sim/stores/modals/search/store.test.ts index c0dd4eb10bb..1d96b0a4662 100644 --- a/apps/sim/stores/modals/search/store.test.ts +++ b/apps/sim/stores/modals/search/store.test.ts @@ -69,7 +69,6 @@ describe('search modal store', () => { tools: [], triggers: [], toolOperations: [], - docs: [], isInitialized: false, }, }) diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts index 1086e815c37..337583e9672 100644 --- a/apps/sim/stores/modals/search/store.ts +++ b/apps/sim/stores/modals/search/store.ts @@ -9,7 +9,6 @@ import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import type { SearchBlockItem, SearchData, - SearchDocItem, SearchModalState, SearchToolOperationItem, } from './types' @@ -19,7 +18,6 @@ const initialData: SearchData = { tools: [], triggers: [], toolOperations: [], - docs: [], isInitialized: false, } @@ -88,7 +86,6 @@ export const useSearchModalStore = create()( const regularBlocks: SearchBlockItem[] = [] const tools: SearchBlockItem[] = [] - const docs: SearchDocItem[] = [] for (const block of filteredAllBlocks) { if (block.hideFromToolbar) continue @@ -108,15 +105,6 @@ export const useSearchModalStore = create()( } else if (block.category === 'tools') { tools.push(searchItem) } - - if (block.docsLink) { - docs.push({ - id: `docs-${block.type}`, - name: block.name, - icon: block.icon, - href: block.docsLink, - }) - } } const specialBlocks: SearchBlockItem[] = [ @@ -192,7 +180,6 @@ export const useSearchModalStore = create()( tools, triggers, toolOperations, - docs, isInitialized: true, }, }) diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index 4ab3972e12f..e7f8c7cee39 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -30,16 +30,6 @@ export interface SearchToolOperationItem { operationId: string } -/** - * Represents a doc item in the search results. - */ -export interface SearchDocItem { - id: string - name: string - icon: ComponentType<{ className?: string }> - href: string -} - /** * Pre-computed search data that is initialized on app load. */ @@ -48,37 +38,9 @@ export interface SearchData { tools: SearchBlockItem[] triggers: SearchBlockItem[] toolOperations: SearchToolOperationItem[] - docs: SearchDocItem[] isInitialized: boolean } -/** - * Every result group the search modal can render, in render order. Used to - * restrict the palette to a subset of sections when opened for a specific - * intent (e.g. a drag-release that should only offer canvas-insertable items). - */ -export const SEARCH_SECTIONS = [ - 'actions', - 'connectedAccounts', - 'integrations', - 'blocks', - 'tools', - 'triggers', - // Resource groups follow the sidebar's top-down order. - 'chats', - 'tables', - 'files', - 'knowledgeBases', - 'workflows', - 'toolOperations', - 'workspaces', - 'docs', - 'pages', -] as const - -/** A single search-modal result group. */ -export type SearchSection = (typeof SEARCH_SECTIONS)[number] - /** * Context handed to the palette when it is opened to complete an edge * drag-release: the dragged source handle and the release point. A selection From b0ecf22e6d471deb3a1822f14c4caa2204f8338c Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:08:01 -0700 Subject: [PATCH 25/29] fix(search): paint the palette fog with the dialog's own surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frost under the floating input reused the canvas card's --surface-2 gradient, which reads as a tinted band on the palette's --surface-4/ --surface-5 dialog (visible in dark mode). The CommandSearch surface variant returns — this time with genuinely different values — and the chrome test pins the host-matching fog. --- .../connection-block-selector.tsx | 1 + .../command-chrome/command-chrome.test.tsx | 7 ++++--- .../command-chrome/command-chrome.tsx | 21 +++++++++++++++---- .../components/search-modal/search-modal.tsx | 1 + 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 77b7deb77a9..110ede75791 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -490,6 +490,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps { root.render( - + ) }) @@ -60,14 +60,15 @@ describe('CommandFadedList', () => { const search = container.querySelector('[cmdk-input]')?.parentElement expect(list?.className).toContain('transparent_8%,black_13%,black_97%') expect(list?.className).not.toContain('scrollbar-track') - expect(search?.className).toContain('var(--surface-2)') + expect(search?.className).toContain('var(--surface-4)') + expect(search?.className).toContain('dark:bg-[linear-gradient(to_bottom,var(--surface-5)') }) it('cycles through palette results with Tab and Shift+Tab', () => { act(() => { root.render( - + First Second diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index ae1aa51f660..9127d2d5c21 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -14,6 +14,7 @@ type CommandInputProps = ComponentPropsWithoutRef type CommandListProps = ComponentPropsWithoutRef interface CommandSearchProps extends Omit { + surface: 'canvas' | 'palette' cycleResultsOnTab?: boolean /** Trailing slot after the input (e.g. a mode hint). Non-interactive. */ endAdornment?: ReactNode @@ -23,8 +24,17 @@ interface CommandFadedListProps extends CommandListProps { fade: 'canvas' | 'palette' } -const SEARCH_SURFACE_CLASSNAME = - 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]' +/** + * The fog must repaint its host's exact background or it reads as a tinted + * band under the input: the canvas selector card fills with `--surface-2`, + * the palette dialog with `--surface-4` (light) / `--surface-5` (dark). + */ +const SEARCH_SURFACE_CLASSNAME = { + canvas: + 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', + palette: + 'bg-[linear-gradient(to_bottom,var(--surface-4)_0%,color-mix(in_srgb,var(--surface-4)_88%,transparent)_68%,transparent_100%)] dark:bg-[linear-gradient(to_bottom,var(--surface-5)_0%,color-mix(in_srgb,var(--surface-5)_88%,transparent)_68%,transparent_100%)]', +} as const /** * The palette hides its scrollbar (`scrollbar-none` at the call site), so it @@ -41,7 +51,10 @@ const LIST_FADE_CLASSNAME = { /** Borderless search field layered over a fading command-result list. */ export const CommandSearch = forwardRef( - function CommandSearch({ cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, ref) { + function CommandSearch( + { surface, cycleResultsOnTab = false, endAdornment, onKeyDown, ...props }, + ref + ) { const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event) if (!cycleResultsOnTab || event.defaultPrevented || event.key !== 'Tab') return @@ -60,7 +73,7 @@ export const CommandSearch = forwardRef(
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 2f8e4a8d850..4864f01c131 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -1249,6 +1249,7 @@ function SearchModalContent({ Date: Mon, 10 Aug 2026 21:11:11 -0700 Subject: [PATCH 26/29] fix(search): the palette fog matches the inner --bg panel, not the dialog ring --- .../components/command-chrome/command-chrome.test.tsx | 3 +-- .../components/command-chrome/command-chrome.tsx | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx index eb76a0cc722..df755fb3041 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.test.tsx @@ -60,8 +60,7 @@ describe('CommandFadedList', () => { const search = container.querySelector('[cmdk-input]')?.parentElement expect(list?.className).toContain('transparent_8%,black_13%,black_97%') expect(list?.className).not.toContain('scrollbar-track') - expect(search?.className).toContain('var(--surface-4)') - expect(search?.className).toContain('dark:bg-[linear-gradient(to_bottom,var(--surface-5)') + expect(search?.className).toContain('var(--bg)') }) it('cycles through palette results with Tab and Shift+Tab', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx index 9127d2d5c21..58308c8c42b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-chrome/command-chrome.tsx @@ -27,13 +27,14 @@ interface CommandFadedListProps extends CommandListProps { /** * The fog must repaint its host's exact background or it reads as a tinted * band under the input: the canvas selector card fills with `--surface-2`, - * the palette dialog with `--surface-4` (light) / `--surface-5` (dark). + * while the palette's rows sit on the inner `--bg` panel (the dialog's + * surface-4/5 is only the 3px ring around it). */ const SEARCH_SURFACE_CLASSNAME = { canvas: 'bg-[linear-gradient(to_bottom,var(--surface-2)_0%,color-mix(in_srgb,var(--surface-2)_88%,transparent)_68%,transparent_100%)]', palette: - 'bg-[linear-gradient(to_bottom,var(--surface-4)_0%,color-mix(in_srgb,var(--surface-4)_88%,transparent)_68%,transparent_100%)] dark:bg-[linear-gradient(to_bottom,var(--surface-5)_0%,color-mix(in_srgb,var(--surface-5)_88%,transparent)_68%,transparent_100%)]', + 'bg-[linear-gradient(to_bottom,var(--bg)_0%,color-mix(in_srgb,var(--bg)_88%,transparent)_68%,transparent_100%)]', } as const /** From 693e2453086a5ce35c1a921951ec6205383970de Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:34:48 -0700 Subject: [PATCH 27/29] fix(search): review-round parity and consistency fixes - table import command respects the in-progress upload gate - Export CSV is offered to viewers (matching the header control) - palette mode flips with the deferred query the ranking ran against - a gated palette deploy reports the button tooltip's reason via toast --- .../workspace/[workspaceId]/tables/tables.tsx | 7 +++++- .../panel/components/deploy/deploy.tsx | 17 ++++++++++++-- .../components/search-modal/search-modal.tsx | 22 +++++++++++-------- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 01be8192160..531f7143475 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -1037,7 +1037,12 @@ export function Tables() { useRegisterGlobalCommands(() => [ { id: 'tables-new-table', handler: () => void handleCreateTable() }, { id: 'tables-new-folder', handler: () => void handleCreateFolder() }, - { id: 'tables-import-csv', handler: () => csvInputRef.current?.click() }, + { + id: 'tables-import-csv', + handler: () => { + if (!uploading) csvInputRef.current?.click() + }, + }, ]) const headerActions: ResourceAction[] = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 0a92b0a63e4..79d56ee4422 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Chip, Tooltip } from '@sim/emcn' +import { Chip, Tooltip, toast } from '@sim/emcn' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { @@ -76,7 +76,20 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: } } - useRegisterGlobalCommands(() => [{ id: 'deploy-workflow', handler: () => void onDeployClick() }]) + useRegisterGlobalCommands(() => [ + { + id: 'deploy-workflow', + handler: () => { + /* The palette can't render a disabled state for this action yet, so a + gated invocation reports the same reason the button's tooltip shows. */ + if (isRegistryLoading || isDisabled) { + toast(isRegistryLoading ? 'Workflow is still loading' : getTooltipText()) + return + } + void onDeployClick() + }, + }, + ]) const getTooltipText = () => { if (isEmpty) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index 4864f01c131..b307f09cc11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -367,6 +367,16 @@ function SearchModalContent({ } ) } + if (pageContext === 'tableDetail') { + list.push({ + id: 'table-export-csv', + name: 'Export CSV', + keywords: 'download spreadsheet', + icon: Download, + context: 'tableDetail', + run: invoke('table-export-csv'), + }) + } if (canEdit && pageContext === 'tableDetail') { list.push( { @@ -377,14 +387,6 @@ function SearchModalContent({ context: 'tableDetail', run: invoke('table-new-column'), }, - { - id: 'table-export-csv', - name: 'Export CSV', - keywords: 'download spreadsheet', - icon: Download, - context: 'tableDetail', - run: invoke('table-export-csv'), - }, { id: 'table-import-csv', name: 'Import CSV', @@ -1065,7 +1067,9 @@ function SearchModalContent({ ]) const searchQuery = search.trim() - const isSearching = Boolean(searchQuery) + /* Mode follows the DEFERRED query the ranking ran against — keying it on the + live value would flip the layout a frame before the entries agree with it. */ + const isSearching = Boolean(deferredSearch.trim()) /** * Section order for both the browse list and the flat search tie-break: the * page's own entity section is hoisted directly under `actions`, the rest From b39bb97dcd2df111d52c5894b9b32375c609b3d6 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:35:31 -0700 Subject: [PATCH 28/29] fix(deploy): use the emcn toast input shape --- .../[workflowId]/components/panel/components/deploy/deploy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 79d56ee4422..fc2ae3461c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -83,7 +83,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: /* The palette can't render a disabled state for this action yet, so a gated invocation reports the same reason the button's tooltip shows. */ if (isRegistryLoading || isDisabled) { - toast(isRegistryLoading ? 'Workflow is still loading' : getTooltipText()) + toast({ message: isRegistryLoading ? 'Workflow is still loading' : getTooltipText() }) return } void onDeployClick() From 61a25594b2fa4b921e4f5626d98a86846e757891 Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:14:25 -0700 Subject: [PATCH 29/29] fix(search): rename logs view toggles to "Switch to Logs/Dashboard" Co-Authored-By: Claude Fable 5 --- .../sidebar/components/search-modal/search-modal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index b307f09cc11..8c56d3a0590 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -550,7 +550,7 @@ function SearchModalContent({ pageContext === 'logs' ? { id: 'logs-show-dashboard', - name: 'Visit dashboard', + name: 'Switch to Dashboard', keywords: 'charts stats overview', icon: Library, context: 'logs', @@ -558,7 +558,7 @@ function SearchModalContent({ } : { id: 'logs-show-logs', - name: 'Visit logs', + name: 'Switch to Logs', keywords: 'list executions runs', icon: Library, context: 'logsDashboard',