diff --git a/.agents/skills/add-connector/SKILL.md b/.agents/skills/add-connector/SKILL.md index 4633b0e5f38..c3ca20556a0 100644 --- a/.agents/skills/add-connector/SKILL.md +++ b/.agents/skills/add-connector/SKILL.md @@ -8,7 +8,7 @@ argument-hint: [api-docs-url] ## Choose the connector runtime first -For **Sim Search**, use the live provider workflow in [the federated Search developer guide](../../../apps/sim/lib/sim-search/live/README.md#adding-a-live-search-connector). Its browser-safe provider catalog owns provider IDs, API origins, credential aliases, and account modes; its typed runtime registry requires both search and read handlers. `ConnectorMeta` remains the owner of logos and setup fields. Member mode has no admin resource filters. Service mode requires independent live source verification and shared selectors. Do not implement a Search source by adding a crawler, embeddings, or a scheduled ACL build. +For **Sim Search**, use the live provider workflow in [the federated Search developer guide](../../../apps/sim/lib/sim-search/live/README.md#adding-a-live-search-connector). Its browser-safe provider catalog owns provider IDs, API origins, credential aliases, and account modes; its typed runtime registry requires both search and read handlers. `ConnectorMeta` remains the owner of logos and setup fields. Member mode has no admin resource filters. Service mode requires independent live source verification; resource pickers use shared selectors with canonical manual-input pairs, and plain inputs are fine where no picker applies. Do not implement a Search source by adding a crawler, embeddings, or a scheduled ACL build. The ingestion instructions below apply to **ordinary knowledge-base connectors** and the explicit legacy Search backend (`SIM_SEARCH_LIVE=false`). If a provider supports both, implement and test both runtimes; adding `search: true` to metadata alone does not implement federated search. Preserve indexing documentation and behavior for those KB/legacy callers. diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 695a28ce839..3702dcf3097 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -252,11 +252,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it, **One member avatar.** Every member list, owner cell, and ranking renders emcn -`` — a 14px photo, or the initial -on the neutral disc (`aria-hidden` because the name is always beside it). Never -hand-roll an avatar or give a person a `getUserColor` hash; per-person colors -belong to live collaboration (presence, cursors), where the color matches that -person's cursor. +`` — a 14px photo, or the initial on the +neutral disc. Pass `aria-hidden` only when the member's name is visibly rendered +beside it; an email-only row (`MemberRow`) keeps the avatar labelled because it +carries the name. Never hand-roll an avatar or give a person a `getUserColor` +hash; per-person colors belong to live collaboration (presence, cursors), where +the color matches that person's cursor. ## Header action order diff --git a/.cursor/rules/sim-settings-pages.mdc b/.cursor/rules/sim-settings-pages.mdc index 7cabb5e583f..d14a9dabc2a 100644 --- a/.cursor/rules/sim-settings-pages.mdc +++ b/.cursor/rules/sim-settings-pages.mdc @@ -249,11 +249,12 @@ and — on activatable rows only — the hover band. Never hand-roll any of it, **One member avatar.** Every member list, owner cell, and ranking renders emcn -`` — a 14px photo, or the initial -on the neutral disc (`aria-hidden` because the name is always beside it). Never -hand-roll an avatar or give a person a `getUserColor` hash; per-person colors -belong to live collaboration (presence, cursors), where the color matches that -person's cursor. +`` — a 14px photo, or the initial on the +neutral disc. Pass `aria-hidden` only when the member's name is visibly rendered +beside it; an email-only row (`MemberRow`) keeps the avatar labelled because it +carries the name. Never hand-roll an avatar or give a person a `getUserColor` +hash; per-person colors belong to live collaboration (presence, cursors), where +the color matches that person's cursor. ## Header action order diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index e2b62480cbb..3a5a0d693d7 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -912,6 +912,21 @@ describe('collectSnapshot', () => { expect(captured.input.ownerDocument).toBe(document) }) + it('climbs out of a shadow root inside a same-origin frame to find the file input', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const childDocument = frame.contentDocument as Document + childDocument.body.innerHTML = '
' + const host = childDocument.getElementById('host') as HTMLElement + const button = childDocument.createElement('button') + host.attachShadow({ mode: 'open' }).append(button) + const input = childDocument.querySelector('input') as HTMLInputElement + register(button) + + expect(button.getRootNode()).not.toBeInstanceOf(ShadowRoot) + expect(resolveFileInputTarget(0)).toEqual({ input, document: childDocument }) + }) + it('refuses a disconnected or stale upload reference', () => { const input = document.createElement('input') input.type = 'file' diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 30b81638104..86c622d10d8 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -3348,7 +3348,7 @@ export function resolveFileInputTarget(id: number): { ) if (candidates.length === 1) input = candidates[0] const root = scope.getRootNode() - scope = scope.parentElement ?? (root instanceof ShadowRoot ? root.host : null) + scope = scope.parentElement ?? ('host' in root ? (root.host as Element) : null) } if (!input) throw new Error('The selected element has no nearby file input.') if (input.matches(':disabled')) throw new Error('The file input is disabled.') diff --git a/apps/sim/app/api/desktop/tool/file/route.test.ts b/apps/sim/app/api/desktop/tool/file/route.test.ts index 5ab3f7b80a6..a9b151a7b3e 100644 --- a/apps/sim/app/api/desktop/tool/file/route.test.ts +++ b/apps/sim/app/api/desktop/tool/file/route.test.ts @@ -6,9 +6,14 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' -const { mockRead, mockSave } = vi.hoisted(() => ({ mockRead: vi.fn(), mockSave: vi.fn() })) +const { mockAdmit, mockRead, mockSave } = vi.hoisted(() => ({ + mockAdmit: vi.fn(), + mockRead: vi.fn(), + mockSave: vi.fn(), +})) vi.mock('@/lib/browser-agent/application/browser-file-transfer', () => ({ + admitBrowserDownloadSave: mockAdmit, readBrowserUploadFile: { operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mockRead, @@ -43,6 +48,7 @@ describe('/api/desktop/tool/file', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue(session) + mockAdmit.mockResolvedValue(undefined) mockRead.mockResolvedValue({ file: { name: 'Q3 plan.pdf' }, content: Buffer.from('%PDF') }) mockSave.mockResolvedValue({ file: { name: 'report.csv', size: 3, folderPath: null, vfsNamespace: 'files' }, @@ -92,6 +98,21 @@ describe('/api/desktop/tool/file', () => { put('toolCallId=c&name=a.csv', new Uint8Array(1), { 'content-length': String(2 ** 31) }) ) expect(oversized.status).toBe(413) + expect(mockAdmit).not.toHaveBeenCalled() + expect(mockSave).not.toHaveBeenCalled() + }) + + it('refuses an unadmitted save without reading its body', async () => { + mockAdmit.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Browser file transfer not found') + ) + const request = put('toolCallId=unknown&name=a.csv', new Uint8Array([1, 2, 3])) + + const res = await PUT(request) + + expect(res.status).toBe(404) + expect(mockAdmit).toHaveBeenCalledWith(principal, { toolCallId: 'unknown', name: 'a.csv' }) + expect(request.bodyUsed).toBe(false) expect(mockSave).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/desktop/tool/file/route.ts b/apps/sim/app/api/desktop/tool/file/route.ts index d4ba1d47f9e..b21cdeea748 100644 --- a/apps/sim/app/api/desktop/tool/file/route.ts +++ b/apps/sim/app/api/desktop/tool/file/route.ts @@ -13,6 +13,7 @@ import { } from '@/lib/api/server/routes' import { withRequestId } from '@/lib/api/server/routes/request-id' import { + admitBrowserDownloadSave, readBrowserUploadFile, saveBrowserDownload, } from '@/lib/browser-agent/application/browser-file-transfer' @@ -52,8 +53,9 @@ export const POST = defineInternalBinaryRoute({ * PUT /api/desktop/tool/file?toolCallId=…&name=… * * Raw `withRouteHandler`: the body is the download's bytes, so it is admitted by declared length - * and read under a byte ceiling only after the session is authenticated, then handed to the - * application use case that binds it to its claimed `browser_save_download` call. + * and read under a byte ceiling only after the session is authenticated and the application has + * admitted the caller's claimed `browser_save_download` call, then handed to the use case that + * re-validates and claims that call. */ export const PUT = withRouteHandler(async (request: NextRequest) => { let principal @@ -73,6 +75,13 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { } const parsed = await parseRequest(saveBrowserDownloadContract, request, {}) if (!parsed.success) return parsed.response + const { toolCallId, name } = parsed.data.query + + try { + await admitBrowserDownloadSave(principal, { toolCallId, name }) + } catch (error) { + return saveErrorResponse(error) + } let content: Buffer try { @@ -93,16 +102,21 @@ export const PUT = withRouteHandler(async (request: NextRequest) => { try { const { file } = await saveBrowserDownload.execute({ principal, - input: { toolCallId: parsed.data.query.toolCallId, name: parsed.data.query.name, content }, + input: { toolCallId, name, content }, request, }) return NextResponse.json({ path: workspaceFileVfsPath(file), name: file.name, size: file.size }) } catch (error) { - const response = internalFileErrorPolicies.concealContentAuthorization.project(error) - if (!response) throw error - return NextResponse.json(withRequestId(response.body), { - status: response.status, - headers: response.headers, - }) + return saveErrorResponse(error) } }) + +/** Projects a typed download-save failure, rethrowing anything the policy does not classify. */ +function saveErrorResponse(error: unknown): NextResponse { + const response = internalFileErrorPolicies.concealContentAuthorization.project(error) + if (!response) throw error + return NextResponse.json(withRequestId(response.body), { + status: response.status, + headers: response.headers, + }) +} diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx index d8d174a7bcd..b0c8e9d5d5e 100644 --- a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx +++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx @@ -6,6 +6,7 @@ import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle' import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import OrganizationChatLoading from '@/app/o/[organizationId]/chat/[chatId]/loading' +import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect' import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' export const metadata: Metadata = { title: 'Chat' } @@ -20,8 +21,8 @@ export default async function OrganizationChatPage({ if (!session?.user?.id) notFound() const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() - if (!context.mothershipAvailable && !context.searchAccess.memberScoped) - redirect(WORKSPACE_SETTINGS_PATH) + const homeRedirect = getOrganizationHomeRedirect(context, organizationId) + if (homeRedirect) redirect(homeRedirect) const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, }) @@ -38,7 +39,6 @@ export default async function OrganizationChatPage({ ) } - if (!context.mothershipAvailable) redirect(WORKSPACE_SETTINGS_PATH) return ( }> , + organizationId: string +): string | null { + if (!context.mothershipAvailable && context.searchAccess.memberScoped) { + return organizationRoutes(organizationId).search + } + if (!(context.mothershipAvailable && context.canBuild) && !context.searchAccess.memberScoped) { + return WORKSPACE_SETTINGS_PATH + } + return null +} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index 1307fe05e22..81e479d73c1 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -69,7 +69,10 @@ vi.mock('@/lib/core/utils/browser-storage', () => ({ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, })) -vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ useChat: mocks.chat })) +vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-chat', () => ({ + getMothershipUseChatOptions: (options: object) => ({ ...options, mothership: true }), + useChat: mocks.chat, +})) vi.mock('@/hooks/queries/mothership-chats', () => ({ useMarkMothershipChatRead: () => ({ mutate: mocks.markRead }), })) @@ -602,7 +605,7 @@ describe('Home permission-selected harness', () => { expect(mocks.chat).toHaveBeenLastCalledWith( { organizationId: 'organization-a' }, 'search-a', - expect.objectContaining({ requestMode: 'assistant', projectsDesktopTabs: false }) + expect.objectContaining({ requestMode: 'assistant', mothership: true }) ) }) it('keeps old Build history readable after permission removal without a writable composer', async () => { @@ -892,6 +895,23 @@ it('restores an explicitly selected search panel on empty Home without reusing t expect(mocks.send).not.toHaveBeenCalled() }) +it('restores a custom date range with the search panel', async () => { + mocks.activeResource = 'search:organization:organization-a' + await act(async () => + renderHome(, '?q=Orion&updated=custom&from=2026-09-01&to=2026-09-03') + ) + expect(mocks.addResource).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.objectContaining({ + filters: { + modifiedAfter: new Date(2026, 8, 1).toISOString(), + modifiedBefore: new Date(2026, 8, 4, 0, 0, 0, -1).toISOString(), + }, + }), + }) + ) +}) + it('does not reopen closed search results merely because a query remains in the URL', async () => { await act(async () => renderHome(, '?searchLevel=adaptive&q=Orion')) expect(mocks.addResource).not.toHaveBeenCalled() diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 1ea4df9bb9c..e2574555b34 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -26,7 +26,10 @@ import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/ import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat' import { SuggestedActions } from '@/app/workspace/[workspaceId]/home/components/suggested-actions' import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' -import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' +import { + getMothershipUseChatOptions, + useChat, +} from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import { useChatResourcePanel, useResourcePanelController, @@ -84,10 +87,8 @@ function OrganizationHomeContent({ const rememberedMode = useOrganizationChatModeStore( (state) => state.modes[`${userId}:${organization.id}`] ) - const [{ q, source, updated, searchLevel: urlSearchLevel }, setSearchParams] = useQueryStates( - organizationHomeParsers, - organizationSearchUrlKeys - ) + const [{ q, source, updated, from, to, searchLevel: urlSearchLevel }, setSearchParams] = + useQueryStates(organizationHomeParsers, organizationSearchUrlKeys) const rememberMode = useOrganizationChatModeStore((state) => state.setMode) const [selectedMode, setSelectedMode] = useState(null) const planEnabled = useFeatureFlag('mothership-plan-mode') @@ -104,12 +105,15 @@ function OrganizationHomeContent({ : 'agent') const controller = useResourcePanelController() const queryClient = useQueryClient() - const chat = useChat({ organizationId: organization.id }, chatId, { - requestMode, - projectsDesktopTabs: requestMode !== 'assistant', - onResourceEvent: controller.onResourceEvent, - activeResourceState: controller.activeResourceState, - }) + const chat = useChat( + { organizationId: organization.id }, + chatId, + getMothershipUseChatOptions({ + requestMode, + onResourceEvent: controller.onResourceEvent, + activeResourceState: controller.activeResourceState, + }) + ) const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` const draftKey = `${userId}:organization:${organization.id}:${chat.resolvedChatId ?? chatId ?? 'new'}` const savedDraft = useMothershipDraftsStore.getState().drafts @@ -143,7 +147,7 @@ function OrganizationHomeContent({ const resource = createSearchResource({ scope: { kind: 'organization', organizationId: organization.id }, query: q.trim(), - filters: searchFiltersFromParams({ source, updated }, Date.now()), + filters: searchFiltersFromParams({ source, updated, from, to }, Date.now()), }) if ( controller.activeResourceParam !== resource.id || @@ -156,6 +160,8 @@ function OrganizationHomeContent({ q, source, updated, + from, + to, organization.id, controller.activeResourceParam, chat.resources, diff --git a/apps/sim/app/o/[organizationId]/home/page.test.tsx b/apps/sim/app/o/[organizationId]/home/page.test.tsx index a3bdfdcba43..d4d7b80c75b 100644 --- a/apps/sim/app/o/[organizationId]/home/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.test.tsx @@ -152,6 +152,24 @@ describe('organization Search page gates', () => { ) expect(mocks.chat).not.toHaveBeenCalled() }) + + it('routes chat URLs exactly where Home routes the same viewer, before loading the chat', async () => { + mocks.context.mockResolvedValue({ + mothershipAvailable: true, + canBuild: false, + searchAccess: { memberScoped: false }, + }) + await expect(OrganizationChatPage({ params })).rejects.toThrow( + 'redirect:/workspace?redirect=settings' + ) + mocks.context.mockResolvedValue({ + mothershipAvailable: false, + canBuild: false, + searchAccess: { memberScoped: true }, + }) + await expect(OrganizationChatPage({ params })).rejects.toThrow('redirect:/o/org-1/search') + expect(mocks.chat).not.toHaveBeenCalled() + }) }) it('renders standalone Search independently of assistant availability', async () => { diff --git a/apps/sim/app/o/[organizationId]/home/page.tsx b/apps/sim/app/o/[organizationId]/home/page.tsx index fd6a14a3672..29e8b0351ce 100644 --- a/apps/sim/app/o/[organizationId]/home/page.tsx +++ b/apps/sim/app/o/[organizationId]/home/page.tsx @@ -2,8 +2,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' -import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { getOrganizationHomeRedirect } from '@/app/o/[organizationId]/home/home-redirect' import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' import { HomeFallback } from '@/app/workspace/[workspaceId]/home/home-fallback' @@ -21,10 +21,8 @@ export default async function OrganizationHomePage({ if (!session?.user?.id) notFound() const context = await getOrganizationSurfaceContext(organizationId, session.user.id) if (!context) notFound() - if (!context.mothershipAvailable && context.searchAccess.memberScoped) - redirect(organizationRoutes(organizationId).search) - if (!(context.mothershipAvailable && context.canBuild) && !context.searchAccess.memberScoped) - redirect(WORKSPACE_SETTINGS_PATH) + const homeRedirect = getOrganizationHomeRedirect(context, organizationId) + if (homeRedirect) redirect(homeRedirect) return ( }> diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index aee463c5e68..5411c931030 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -18,6 +18,7 @@ import { cn, toast } from '@sim/emcn' import { formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import type { editor as MonacoEditorTypes } from 'monaco-editor' import dynamic from 'next/dynamic' +import { readSeparatorKey } from '@/lib/core/utils/separator-keys' import { buildFileSelectionLabel, truncateSelectionText, @@ -627,27 +628,17 @@ export const TextEditor = memo(function TextEditor({ }, [isResizing]) const handleSplitKeyDown = (event: ReactKeyboardEvent) => { - if ( - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - event.nativeEvent.isComposing || - event.keyCode === 229 - ) { - return - } - const { key } = event - if (key !== 'ArrowLeft' && key !== 'ArrowRight' && key !== 'Home' && key !== 'End') return + const key = readSeparatorKey(event) + if (!key) return event.preventDefault() event.stopPropagation() - if (key === 'Home' || key === 'End') { - setSplitPct(key === 'Home' ? SPLIT_MIN_PCT : SPLIT_MAX_PCT) + if (key === 'min' || key === 'max') { + setSplitPct(key === 'min' ? SPLIT_MIN_PCT : SPLIT_MAX_PCT) return } const container = containerRef.current const isRtl = container !== null && getComputedStyle(container).direction === 'rtl' - const delta = (key === 'ArrowLeft' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT + const delta = (key === 'left' ? -1 : 1) * (isRtl ? -1 : 1) * SPLIT_KEYBOARD_STEP_PCT setSplitPct((current) => Math.min(SPLIT_MAX_PCT, Math.max(SPLIT_MIN_PCT, current + delta))) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.test.tsx index 76c1cb34678..83790423be9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.test.tsx @@ -67,6 +67,8 @@ it.each([1, 2])( activityCount={count} onToggle={toggle} onResize={vi.fn()} + onResizeKeyDown={vi.fn()} + onResizeFocus={vi.fn()} panel={
Resources
} >
Conversation
@@ -87,3 +89,39 @@ it.each([1, 2])( expect(container.textContent).toContain('Resources') } ) + +it('exposes the divider as a focusable separator routing keys and focus to the owner', async () => { + const resize = vi.fn() + const keyDown = vi.fn() + const focus = vi.fn() + await act(async () => + root.render( + Resources} + > +
Conversation
+
+ ) + ) + document.body.appendChild(container) + const separator = container.querySelector('[role="separator"]')! + expect(separator.getAttribute('aria-label')).toBe('Resize resource view') + expect(separator.getAttribute('aria-orientation')).toBe('vertical') + expect(separator.tabIndex).toBe(0) + await act(async () => separator.focus()) + expect(document.activeElement).toBe(separator) + expect(focus).toHaveBeenCalledOnce() + await act(async () => { + separator.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })) + }) + expect(keyDown).toHaveBeenCalledOnce() + expect(keyDown.mock.calls[0][0].key).toBe('ArrowLeft') + expect(resize).not.toHaveBeenCalled() + container.remove() +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx index 5a30d9bb490..bb83b438510 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-panel-layout.tsx @@ -1,6 +1,12 @@ 'use client' -import type { PointerEventHandler, ReactNode, Ref } from 'react' +import type { + FocusEventHandler, + KeyboardEventHandler, + PointerEventHandler, + ReactNode, + Ref, +} from 'react' import { Button, cn } from '@sim/emcn' import { PanelLeft } from '@sim/emcn/icons' import { RESOURCE_HEADER_CLASSES } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' @@ -13,6 +19,9 @@ interface ChatPanelLayoutProps { activityCount?: number onToggle: () => void onResize: PointerEventHandler + onResizeKeyDown: KeyboardEventHandler + /** Reports the panel's current width and bounds on the divider's `aria-value*`. */ + onResizeFocus: FocusEventHandler } /** Shared resize handle and collapse control for resources and Search results. */ @@ -24,6 +33,8 @@ export function ChatPanelLayout({ activityCount = 0, onToggle, onResize, + onResizeKeyDown, + onResizeFocus, }: ChatPanelLayoutProps) { const toggleLabel = `${collapsed ? 'Expand' : 'Collapse'} ${label}${ collapsed && activityCount > 0 @@ -38,11 +49,14 @@ export function ChatPanelLayout({ {!collapsed && (
)} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx index d116e23b2dd..1b67be0a675 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx @@ -59,6 +59,8 @@ export function ChatResourcePanel({ expandResource, mothershipRef, handleResourceResizePointerDown, + handleResourceResizeKeyDown, + handleResourceResizeFocus, handleResourceInteraction, } = panel const summarize = useCallback( @@ -75,6 +77,8 @@ export function ChatResourcePanel({ activityCount={resourceActivityIds.size} onToggle={isResourceCollapsed ? expandResource : collapseResource} onResize={handleResourceResizePointerDown} + onResizeKeyDown={handleResourceResizeKeyDown} + onResizeFocus={handleResourceResizeFocus} panel={ { expect(container.textContent).not.toContain('Built API') }) + function renderBuildActivity(lastStatus: ToolCallStatus) { + const read = (id: string): AgentGroupItem => ({ + type: 'tool', + data: { id, toolName: 'read', displayTitle: `Read ${id}`, status: 'success' }, + }) + act(() => + root.render( + createElement(AgentGroupView, { + agentName: 'mothership', + agentLabel: 'Sim', + activity: { id: 'build', title: 'Building API', completedTitle: 'Built API' }, + items: [ + read('config'), + read('schema'), + { + type: 'tool', + data: { + id: 'create', + toolName: 'create', + displayTitle: 'Create route', + status: lastStatus, + }, + }, + ], + ToolCallComponent: ({ displayTitle, renderStatus }: ToolCallItemProps) => + renderStatus + ? renderStatus({ label: displayTitle, activeLabel: displayTitle, isActive: false }) + : createElement('div', { 'data-pending': 'true' }, displayTitle), + }) + ) + ) + } + + it('keeps the completed title off a finished multi-call run while its activity awaits approval', () => { + renderBuildActivity('awaiting_approval') + expect(container.textContent).not.toContain('Built API') + expect(container.querySelector('[data-pending]')?.textContent).toBe('Create route') + }) + + it('shows the completed title on a multi-call run once its activity has finished', () => { + renderBuildActivity('success') + expect(container.querySelector('[role="status"]')?.textContent).toBe('Built API') + }) + it.each([ [ 'approval', diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx index 1417b0cedb6..f0d9da25cb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity.tsx @@ -1,5 +1,7 @@ import { type ComponentType, Fragment, type ReactNode } from 'react' +import { omit } from '@sim/utils/object' import type { ToolActivity } from '@/lib/mothership/generated/protocol' +import { isAgentGroupResolved } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view' import { splitMainLane } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/lane-activity' import { ToolActivityGroup } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' @@ -18,6 +20,9 @@ interface MainAgentActivityProps { /** * The main lane's runs and interaction cards; * which run is live is decided by the lane, never by a run's position. + * A run reads the activity's completed title only once every call of the + * activity has finished, so a finished run before a pending approval or + * handoff never claims the whole activity is done. */ export function MainAgentActivity({ activity: groupActivity, @@ -28,6 +33,10 @@ export function MainAgentActivity({ liveToolId, }: MainAgentActivityProps) { const entries = splitMainLane(items) + const runActivity = + groupActivity && !isAgentGroupResolved(items) + ? omit(groupActivity, ['completedTitle']) + : groupActivity const activity = entries.map((entry) => { if (entry.type === 'item') { return ( @@ -41,7 +50,7 @@ export function MainAgentActivity({ tool.id === liveToolId)} ToolCallComponent={ToolCallComponent} autoScrollActivity={autoScrollActivity} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx index 8abd36952c4..007c1fde8fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity.test.tsx @@ -2,6 +2,7 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations' import { MainAgentActivity } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/main-agent-activity' import { ToolCallItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' @@ -311,6 +312,26 @@ describe('search in shared tool activity', () => { expect(container.textContent).not.toContain('No results') }) + function renderReloadedEmptySearch(status: 'partial' | 'complete') { + const output = compactRetrievalCitations('search_workspace', { + success: true, + data: { results: [], retrieval: { status, timedOutLegs: [] } }, + }) + render([{ ...tool, status: 'success', result: { success: true, output } }]) + } + + it('never claims no results for a reloaded partial empty search', () => { + renderReloadedEmptySearch('partial') + expect(header()).toBeNull() + expect(container.textContent).not.toContain('No results') + }) + + it('still reports no results for a reloaded complete empty search', () => { + renderReloadedEmptySearch('complete') + expand() + expect(container.textContent).toContain('No results') + }) + it('preserves available source matches when retrieval is partial', () => { const search = completedSearch('partial', 'Available matches') const output = search.result!.output as { data: Record } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts deleted file mode 100644 index d770809ec63..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - type DragGeometry, - dividerXAt, - maxPanelWidth, - panelWidthAt, -} from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-resize' -import { MOTHERSHIP_WIDTH } from '@/stores/constants' - -/** - * The workspace chrome insets the panel from the viewport edge by its padding - * (8px) plus its border (1px), so a 1512px window puts the panel's right edge - * at 1503 — the number the whole coordinate frame hangs on. - */ -const VIEWPORT = 1512 -const PANEL_RIGHT = VIEWPORT - 9 -const CONTAINER = 1254 - -function geometry(overrides: Partial = {}): DragGeometry { - return { - panelRight: PANEL_RIGHT, - grabOffset: 0, - maxWidth: maxPanelWidth(VIEWPORT, CONTAINER), - ...overrides, - } -} - -describe('maxPanelWidth', () => { - it('yields to the chat column when its min-width is the tighter ceiling', () => { - // 1512 * 0.8 = 1209.6, but the chat's 240px floor only leaves 1014. - expect(maxPanelWidth(VIEWPORT, CONTAINER)).toBe(CONTAINER - MOTHERSHIP_WIDTH.CHAT_MIN) - }) - - it('yields to the viewport share when the container is roomy', () => { - expect(maxPanelWidth(VIEWPORT, 4000)).toBe(VIEWPORT * MOTHERSHIP_WIDTH.MAX_PERCENTAGE) - }) - - it('never returns less than the panel minimum', () => { - // Minimum window size with the sidebar expanded cannot satisfy both. - expect(maxPanelWidth(800, 500)).toBe(MOTHERSHIP_WIDTH.MIN) - }) -}) - -describe('panelWidthAt', () => { - it('measures the width from the panel edge, not the viewport edge', () => { - // Dragging to clientX leaves panelRight - clientX of panel, so a pointer at - // the container's midpoint must not produce a viewport-relative width. - expect(panelWidthAt(1000, geometry())).toBe(PANEL_RIGHT - 1000) - expect(panelWidthAt(1000, geometry())).not.toBe(VIEWPORT - 1000) - }) - - it('honours the grab offset so the edge does not jump to the cursor', () => { - const grabbed = geometry({ grabOffset: 4 }) - expect(panelWidthAt(1000, grabbed)).toBe(PANEL_RIGHT - 996) - }) - - it('clamps to the minimum and the maximum', () => { - expect(panelWidthAt(PANEL_RIGHT, geometry())).toBe(MOTHERSHIP_WIDTH.MIN) - expect(panelWidthAt(0, geometry())).toBe(maxPanelWidth(VIEWPORT, CONTAINER)) - }) -}) - -describe('dividerXAt', () => { - /** - * The invariant the native browser view depends on: the divider position - * reported to the desktop shell is exactly where the width write puts the - * panel's left edge. When these drifted apart the shell composited the page - * beside the panel rather than on it, leaving a band of panel background that - * flickered as predicted and measured rects alternated each frame. - */ - it('always agrees with the edge the width write produces', () => { - for (const grabOffset of [-4, 0, 4]) { - const g = geometry({ grabOffset }) - for (let clientX = 0; clientX <= VIEWPORT; clientX += 7) { - expect(dividerXAt(clientX, g)).toBe(g.panelRight - panelWidthAt(clientX, g)) - } - } - }) - - it('tracks the pointer while both clamps are slack', () => { - expect(dividerXAt(1000, geometry())).toBe(1000) - }) - - it('stops at the edge the chat column pins it to, and goes no further', () => { - const g = geometry() - const pinned = g.panelRight - maxPanelWidth(VIEWPORT, CONTAINER) - // Past the ceiling the divider must hold still. It used to keep travelling, - // because the width ceiling ignored the chat's min-width while the flex row - // did not — a dead band in which the panel froze but the reported divider, - // and with it the native view, kept moving left. - expect(dividerXAt(pinned, g)).toBe(pinned) - expect(dividerXAt(pinned - 60, g)).toBe(pinned) - }) - - it('never reports a divider outside the panel', () => { - const g = geometry() - for (let clientX = -200; clientX <= VIEWPORT + 200; clientX += 13) { - const x = dividerXAt(clientX, g) - expect(x).toBeLessThanOrEqual(g.panelRight - MOTHERSHIP_WIDTH.MIN) - expect(x).toBeGreaterThanOrEqual(g.panelRight - g.maxWidth) - } - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.tsx new file mode 100644 index 00000000000..942af4b38d2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.tsx @@ -0,0 +1,239 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' +import { + type DragGeometry, + dividerXAt, + KEYBOARD_STEP_PX, + keyboardPanelWidth, + maxPanelWidth, + panelWidthAt, + useMothershipResize, +} from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-resize' +import { MOTHERSHIP_WIDTH } from '@/stores/constants' + +vi.mock('@/lib/browser-agent/transport', () => ({ beginBrowserPanelDividerDrag: () => null })) + +/** + * The workspace chrome insets the panel from the viewport edge by its padding + * (8px) plus its border (1px), so a 1512px window puts the panel's right edge + * at 1503 — the number the whole coordinate frame hangs on. + */ +const VIEWPORT = 1512 +const PANEL_RIGHT = VIEWPORT - 9 +const CONTAINER = 1254 + +function geometry(overrides: Partial = {}): DragGeometry { + return { + panelRight: PANEL_RIGHT, + grabOffset: 0, + maxWidth: maxPanelWidth(VIEWPORT, CONTAINER), + ...overrides, + } +} + +describe('maxPanelWidth', () => { + it('yields to the chat column when its min-width is the tighter ceiling', () => { + // 1512 * 0.8 = 1209.6, but the chat's 240px floor only leaves 1014. + expect(maxPanelWidth(VIEWPORT, CONTAINER)).toBe(CONTAINER - MOTHERSHIP_WIDTH.CHAT_MIN) + }) + + it('yields to the viewport share when the container is roomy', () => { + expect(maxPanelWidth(VIEWPORT, 4000)).toBe(VIEWPORT * MOTHERSHIP_WIDTH.MAX_PERCENTAGE) + }) + + it('never returns less than the panel minimum', () => { + // Minimum window size with the sidebar expanded cannot satisfy both. + expect(maxPanelWidth(800, 500)).toBe(MOTHERSHIP_WIDTH.MIN) + }) +}) + +describe('panelWidthAt', () => { + it('measures the width from the panel edge, not the viewport edge', () => { + // Dragging to clientX leaves panelRight - clientX of panel, so a pointer at + // the container's midpoint must not produce a viewport-relative width. + expect(panelWidthAt(1000, geometry())).toBe(PANEL_RIGHT - 1000) + expect(panelWidthAt(1000, geometry())).not.toBe(VIEWPORT - 1000) + }) + + it('honours the grab offset so the edge does not jump to the cursor', () => { + const grabbed = geometry({ grabOffset: 4 }) + expect(panelWidthAt(1000, grabbed)).toBe(PANEL_RIGHT - 996) + }) + + it('clamps to the minimum and the maximum', () => { + expect(panelWidthAt(PANEL_RIGHT, geometry())).toBe(MOTHERSHIP_WIDTH.MIN) + expect(panelWidthAt(0, geometry())).toBe(maxPanelWidth(VIEWPORT, CONTAINER)) + }) +}) + +describe('dividerXAt', () => { + /** + * The invariant the native browser view depends on: the divider position + * reported to the desktop shell is exactly where the width write puts the + * panel's left edge. When these drifted apart the shell composited the page + * beside the panel rather than on it, leaving a band of panel background that + * flickered as predicted and measured rects alternated each frame. + */ + it('always agrees with the edge the width write produces', () => { + for (const grabOffset of [-4, 0, 4]) { + const g = geometry({ grabOffset }) + for (let clientX = 0; clientX <= VIEWPORT; clientX += 7) { + expect(dividerXAt(clientX, g)).toBe(g.panelRight - panelWidthAt(clientX, g)) + } + } + }) + + it('tracks the pointer while both clamps are slack', () => { + expect(dividerXAt(1000, geometry())).toBe(1000) + }) + + it('stops at the edge the chat column pins it to, and goes no further', () => { + const g = geometry() + const pinned = g.panelRight - maxPanelWidth(VIEWPORT, CONTAINER) + // Past the ceiling the divider must hold still. It used to keep travelling, + // because the width ceiling ignored the chat's min-width while the flex row + // did not — a dead band in which the panel froze but the reported divider, + // and with it the native view, kept moving left. + expect(dividerXAt(pinned, g)).toBe(pinned) + expect(dividerXAt(pinned - 60, g)).toBe(pinned) + }) + + it('never reports a divider outside the panel', () => { + const g = geometry() + for (let clientX = -200; clientX <= VIEWPORT + 200; clientX += 13) { + const x = dividerXAt(clientX, g) + expect(x).toBeLessThanOrEqual(g.panelRight - MOTHERSHIP_WIDTH.MIN) + expect(x).toBeGreaterThanOrEqual(g.panelRight - g.maxWidth) + } + }) +}) + +describe('keyboardPanelWidth', () => { + const max = maxPanelWidth(VIEWPORT, CONTAINER) + + it('moves the divider the way the arrow points', () => { + expect(keyboardPanelWidth('left', 600, max)).toBe(600 + KEYBOARD_STEP_PX) + expect(keyboardPanelWidth('right', 600, max)).toBe(600 - KEYBOARD_STEP_PX) + }) + + it('clamps arrow steps to the same bounds as the drag', () => { + expect(keyboardPanelWidth('right', MOTHERSHIP_WIDTH.MIN + 1, max)).toBe(MOTHERSHIP_WIDTH.MIN) + expect(keyboardPanelWidth('left', max - 1, max)).toBe(max) + }) + + it('jumps to the narrowest and widest panel on Home and End', () => { + expect(keyboardPanelWidth('min', 600, max)).toBe(MOTHERSHIP_WIDTH.MIN) + expect(keyboardPanelWidth('max', 600, max)).toBe(max) + }) +}) + +describe('useMothershipResize keyboard divider', () => { + async function mountDivider() { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + function Harness() { + const { mothershipRef, handleResizeKeyDown, handleResizeFocus } = useMothershipResize('s') + return ( +
+
+
+
+ ) + } + await act(async () => root.render()) + const separator = container.querySelector('[role="separator"]')! + const panel = container.querySelector('[data-testid="panel"]')! + Object.defineProperty(panel.parentElement!, 'clientWidth', { value: CONTAINER }) + vi.stubGlobal('innerWidth', VIEWPORT) + panel.getBoundingClientRect = () => { + const width = Number.parseFloat(panel.style.width) || 600 + return { width, left: PANEL_RIGHT - width, right: PANEL_RIGHT } as DOMRect + } + const press = (key: string, init: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }) + act(() => { + separator.dispatchEvent(event) + }) + return event + } + const unmount = async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + } + return { separator, panel, press, unmount } + } + + it('steps and clamps the panel width through the drag clamps and reports it', async () => { + const { separator, panel, press, unmount } = await mountDivider() + const max = maxPanelWidth(VIEWPORT, CONTAINER) + + await act(async () => separator.focus()) + expect(separator.getAttribute('aria-valuenow')).toBe('600') + expect(separator.getAttribute('aria-valuemin')).toBe(String(MOTHERSHIP_WIDTH.MIN)) + expect(separator.getAttribute('aria-valuemax')).toBe(String(Math.round(max))) + + expect(press('ArrowLeft').defaultPrevented).toBe(true) + expect(panel.style.width).toBe(`${600 + KEYBOARD_STEP_PX}px`) + expect(separator.getAttribute('aria-valuenow')).toBe(String(600 + KEYBOARD_STEP_PX)) + expect(panel.style.transition).toBe('') + + press('End') + expect(panel.style.width).toBe(`${max}px`) + press('Home') + expect(panel.style.width).toBe(`${MOTHERSHIP_WIDTH.MIN}px`) + press('ArrowRight') + expect(panel.style.width).toBe(`${MOTHERSHIP_WIDTH.MIN}px`) + await unmount() + }) + + it('keeps a focused divider reporting the width a window resize clamps it to', async () => { + const { separator, panel, press, unmount } = await mountDivider() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + await act(async () => separator.focus()) + press('End') + const wideMax = maxPanelWidth(VIEWPORT, CONTAINER) + expect(separator.getAttribute('aria-valuenow')).toBe(String(Math.round(wideMax))) + + const narrowViewport = 800 + vi.stubGlobal('innerWidth', narrowViewport) + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + const narrowMax = Math.round(maxPanelWidth(narrowViewport, CONTAINER)) + expect(narrowMax).toBeLessThan(Math.round(wideMax)) + expect(panel.style.width).toBe(`${maxPanelWidth(narrowViewport, CONTAINER)}px`) + expect(separator.getAttribute('aria-valuemax')).toBe(String(narrowMax)) + expect(separator.getAttribute('aria-valuenow')).toBe(String(narrowMax)) + await unmount() + }) + + it('leaves modified and unrelated keys to the rest of the page', async () => { + const { panel, press, unmount } = await mountDivider() + for (const event of [ + press('ArrowLeft', { metaKey: true }), + press('ArrowLeft', { shiftKey: true }), + press('Tab'), + press('Enter'), + ]) { + expect(event.defaultPrevented).toBe(false) + } + expect(panel.style.width).toBe('') + await unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts index d7afdd18731..f9e543a4f8e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' import { beginBrowserPanelDividerDrag } from '@/lib/browser-agent/transport' +import { readSeparatorKey, type SeparatorKey } from '@/lib/core/utils/separator-keys' import { MOTHERSHIP_WIDTH } from '@/stores/constants' /** @@ -64,6 +65,45 @@ export function dividerXAt(clientX: number, geometry: DragGeometry): number { return geometry.panelRight - panelWidthAt(clientX, geometry) } +/** Width one arrow-key press on the divider moves the panel by, in CSS px. */ +export const KEYBOARD_STEP_PX = 32 + +/** + * Panel width for a separator key on the focused divider. The divider is the + * panel's left edge, so moving it left widens the panel; Home and End jump to + * the narrowest and widest the drag allows, with the same clamps as + * {@link panelWidthAt}. + */ +export function keyboardPanelWidth( + key: SeparatorKey, + currentWidth: number, + maxWidth: number +): number { + if (key === 'min') return MOTHERSHIP_WIDTH.MIN + if (key === 'max') return maxWidth + const delta = key === 'left' ? KEYBOARD_STEP_PX : -KEYBOARD_STEP_PX + return Math.max(MOTHERSHIP_WIDTH.MIN, Math.min(currentWidth + delta, maxWidth)) +} + +/** + * Pins a width without animating to it. The panel's width transition would + * otherwise make the embedded browser view chase a moving rect for 200ms. + */ +function writeWidthInstantly(el: HTMLElement, width: number) { + const prevTransition = el.style.transition + el.style.transition = 'none' + el.style.width = `${width}px` + void el.offsetWidth + el.style.transition = prevTransition +} + +/** Mirrors the panel's current width and bounds onto the divider for assistive tech. */ +function syncDividerValue(handle: HTMLElement, el: HTMLElement, maxWidth = measureMaxWidth(el)) { + handle.setAttribute('aria-valuemin', String(MOTHERSHIP_WIDTH.MIN)) + handle.setAttribute('aria-valuemax', String(Math.round(maxWidth))) + handle.setAttribute('aria-valuenow', String(Math.round(el.getBoundingClientRect().width))) +} + /** * Hook for managing resize of the MothershipView resource panel. * @@ -71,11 +111,14 @@ export function dividerXAt(clientX: number, geometry: DragGeometry): number { * Pointer Events + setPointerCapture for unified mouse/touch/stylus support. * Attach `mothershipRef` to the MothershipView root div and bind * `handleResizePointerDown` to the drag handle's onPointerDown. + * Bind `handleResizeKeyDown` and `handleResizeFocus` to the same handle so it is + * keyboard-adjustable and reports its value to assistive tech. * Call `clearWidth` when the panel collapses so the CSS class retakes control. */ export function useMothershipResize(desktopScopeId: string) { const mothershipRef = useRef(null) const cleanupRef = useRef<(() => void) | null>(null) + const focusedDividerRef = useRef(null) const desktopScopeIdRef = useRef(desktopScopeId) desktopScopeIdRef.current = desktopScopeId @@ -148,6 +191,7 @@ export function useMothershipResize(desktopScopeId: string) { document.body.style.cursor = '' document.body.style.userSelect = '' cleanupRef.current = null + syncDividerValue(handle, el) } cleanupRef.current = cleanup @@ -213,17 +257,14 @@ export function useMothershipResize(desktopScopeId: string) { const clampWidth = () => { rafId = null const el = mothershipRef.current - const pinned = el?.style.width - if (!el || !pinned) return + if (!el) return + const pinned = el.style.width + const divider = focusedDividerRef.current + const reportsToDivider = divider !== null && document.activeElement === divider + if (!pinned && !reportsToDivider) return const maxWidth = measureMaxWidth(el) - if (Number.parseFloat(pinned) <= maxWidth) return - const prevTransition = el.style.transition - el.style.transition = 'none' - el.style.width = `${maxWidth}px` - // Force the clamped width to be picked up before transitions come back, - // so restoring the property cannot animate from the pre-clamp width. - void el.offsetWidth - el.style.transition = prevTransition + if (pinned && Number.parseFloat(pinned) > maxWidth) writeWidthInstantly(el, maxWidth) + if (reportsToDivider) syncDividerValue(divider, el, maxWidth) } const handleWindowResize = () => { @@ -238,10 +279,36 @@ export function useMothershipResize(desktopScopeId: string) { } }, []) + /** Steps the panel width from the focused divider, never during a live drag. */ + const handleResizeKeyDown = useCallback((e: React.KeyboardEvent) => { + const key = readSeparatorKey(e) + const el = mothershipRef.current + if (!key || !el || cleanupRef.current) return + const maxWidth = measureMaxWidth(el) + const width = keyboardPanelWidth(key, el.getBoundingClientRect().width, maxWidth) + e.preventDefault() + e.stopPropagation() + writeWidthInstantly(el, width) + syncDividerValue(e.currentTarget, el, maxWidth) + }, []) + + /** Reports the current width when the divider takes focus, and while it keeps focus. */ + const handleResizeFocus = useCallback((e: React.FocusEvent) => { + focusedDividerRef.current = e.currentTarget + const el = mothershipRef.current + if (el) syncDividerValue(e.currentTarget, el) + }, []) + /** Remove inline width so the collapse CSS class retakes control */ const clearWidth = useCallback(() => { mothershipRef.current?.style.removeProperty('width') }, []) - return { mothershipRef, handleResizePointerDown, clearWidth } + return { + mothershipRef, + handleResizePointerDown, + handleResizeKeyDown, + handleResizeFocus, + clearWidth, + } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.test.tsx index 1d8a16fd426..0bac6f40812 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { act } from 'react' +import { act, type KeyboardEvent } from 'react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -13,6 +13,10 @@ vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-mothership-resize', () => useMothershipResize: () => ({ mothershipRef: { current: null }, handleResizePointerDown: vi.fn(), + handleResizeKeyDown: (event: { key: string; preventDefault: () => void }) => { + if (event.key === 'ArrowLeft') event.preventDefault() + }, + handleResizeFocus: vi.fn(), clearWidth: mocks.clearWidth, }), })) @@ -99,3 +103,21 @@ it.each([false, true])( ) } ) + +it('claims the resource view only when a divider key actually resizes it', async () => { + const press = (key: string) => { + const event = { + key, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true + }, + } + panel.handleResourceResizeKeyDown(event as unknown as KeyboardEvent) + } + panel.resourceSelectionOwnedByUserRef.current = false + press('Tab') + expect(panel.resourceSelectionOwnedByUserRef.current).toBe(false) + press('ArrowLeft') + expect(panel.resourceSelectionOwnedByUserRef.current).toBe(true) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts index 25ecaf98bef..71e6544e98e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-resource-panel.ts @@ -2,6 +2,7 @@ import { type Dispatch, + type KeyboardEvent, type PointerEvent, type SetStateAction, useCallback, @@ -185,7 +186,13 @@ export function useChatResourcePanel( effectiveActiveResourceIdRef, onResourceEvent: handleResourceEvent, } = controller - const { mothershipRef, handleResizePointerDown, clearWidth } = useMothershipResize(desktopScopeId) + const { + mothershipRef, + handleResizePointerDown, + handleResizeKeyDown, + handleResizeFocus, + clearWidth, + } = useMothershipResize(desktopScopeId) effectiveActiveResourceIdRef.current = activeResourceId const resourceAttentionChatIdRef = useRef(resolvedChatId) @@ -255,6 +262,14 @@ export function useChatResourcePanel( [handleResizePointerDown] ) + const handleResourceResizeKeyDown = useCallback( + (event: KeyboardEvent) => { + handleResizeKeyDown(event) + if (event.defaultPrevented) resourceSelectionOwnedByUserRef.current = true + }, + [handleResizeKeyDown] + ) + const handleResourceInteraction = useCallback(() => { resourceSelectionOwnedByUserRef.current = true }, []) @@ -315,6 +330,8 @@ export function useChatResourcePanel( selectResourceFromUser, addResourceFromUser, handleResourceResizePointerDown, + handleResourceResizeKeyDown, + handleResourceResizeFocus: handleResizeFocus, handleResourceInteraction, prepareResourceViewForAgentTurn, } diff --git a/apps/sim/lib/browser-agent/application/browser-file-transfer.test.ts b/apps/sim/lib/browser-agent/application/browser-file-transfer.test.ts index c7068e7992f..5c2fe27d588 100644 --- a/apps/sim/lib/browser-agent/application/browser-file-transfer.test.ts +++ b/apps/sim/lib/browser-agent/application/browser-file-transfer.test.ts @@ -47,6 +47,7 @@ vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ })) import { + admitBrowserDownloadSave, readBrowserUploadFile, saveBrowserDownload, } from '@/lib/browser-agent/application/browser-file-transfer' @@ -231,6 +232,28 @@ describe('browser file transfer use cases', () => { expect(mocks.createFile).not.toHaveBeenCalled() }) + it('admits a download save without claiming, creating, or auditing, and refuses a foreign run', async () => { + mocks.getAsyncToolCall.mockResolvedValue( + claimedCall('browser_save_download', { downloadId: 'd1' }) + ) + await expect( + admitBrowserDownloadSave(principal, { toolCallId: 'call-1', name: 'report.csv' }) + ).resolves.toBeUndefined() + + mocks.getRunSegment.mockResolvedValue({ + id: 'run-1', + userId: 'someone-else', + workspaceId: 'workspace-1', + chatId: 'chat-1', + }) + await expect( + admitBrowserDownloadSave(principal, { toolCallId: 'call-1', name: 'report.csv' }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.claimDownload).not.toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + it('falls back to the downloaded file name and never saves for an upload call', async () => { mocks.getAsyncToolCall.mockResolvedValueOnce( claimedCall('browser_save_download', { downloadId: 'd1' }) diff --git a/apps/sim/lib/browser-agent/application/browser-file-transfer.ts b/apps/sim/lib/browser-agent/application/browser-file-transfer.ts index ccd53e5d3fb..90dfa6708b0 100644 --- a/apps/sim/lib/browser-agent/application/browser-file-transfer.ts +++ b/apps/sim/lib/browser-agent/application/browser-file-transfer.ts @@ -103,39 +103,66 @@ export const readBrowserUploadFile = defineAuthorizedWorkspaceFileUseCase({ }, }) -export interface SaveBrowserDownloadInput { +export interface AdmitBrowserDownloadSaveInput { toolCallId: string /** The download's file name on this machine; the call's own `name` argument wins. */ name: string +} + +export interface SaveBrowserDownloadInput extends AdmitBrowserDownloadSaveInput { content: Buffer } +async function resolveBrowserDownloadSaveContext({ + principal, + input, +}: { + principal: Principal + input: AdmitBrowserDownloadSaveInput +}) { + const binding = await loadBrowserFileTransferBinding( + principal, + input.toolCallId, + 'browser_save_download' + ) + const workspace = await loadActiveWorkspaceContext(binding.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + const requestedName = binding.args.name + return { + ...workspace, + name: + typeof requestedName === 'string' && requestedName.trim() ? requestedName.trim() : input.name, + } +} + +const admitBrowserDownloadSaveUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: resolveBrowserDownloadSaveContext, + async execute() {}, +}) + +/** + * Admits a `browser_save_download` upload before its body is read, so a caller without a claimed, + * running call of its own is refused without buffering the download. {@link saveBrowserDownload} + * re-validates and claims the call atomically after the read. + */ +export async function admitBrowserDownloadSave( + principal: Principal, + input: AdmitBrowserDownloadSaveInput +): Promise { + await admitBrowserDownloadSaveUseCase.execute({ principal, input }) +} + /** Stores a completed browser download as a workspace file for a claimed `browser_save_download` call. */ export const saveBrowserDownload = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.create, - async resolveContext({ + resolveContext: ({ principal, input, }: { principal: Principal input: SaveBrowserDownloadInput - }) { - const binding = await loadBrowserFileTransferBinding( - principal, - input.toolCallId, - 'browser_save_download' - ) - const workspace = await loadActiveWorkspaceContext(binding.workspaceId) - if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - const requestedName = binding.args.name - return { - ...workspace, - name: - typeof requestedName === 'string' && requestedName.trim() - ? requestedName.trim() - : input.name, - } - }, + }) => resolveBrowserDownloadSaveContext({ principal, input }), async execute({ principal, input, context }) { if (!(await claimBrowserDownloadSave(input.toolCallId))) { throw new OrchestrationError( diff --git a/apps/sim/lib/core/utils/separator-keys.test.ts b/apps/sim/lib/core/utils/separator-keys.test.ts new file mode 100644 index 00000000000..5e8a1557e35 --- /dev/null +++ b/apps/sim/lib/core/utils/separator-keys.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import type { KeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' +import { readSeparatorKey } from '@/lib/core/utils/separator-keys' + +function keyEvent(key: string, init: Partial = {}): KeyboardEvent { + return { + key, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + keyCode: 0, + nativeEvent: { isComposing: false }, + ...init, + } as KeyboardEvent +} + +describe('readSeparatorKey', () => { + it('maps arrows to directions and Home/End to bounds', () => { + expect(readSeparatorKey(keyEvent('ArrowLeft'))).toBe('left') + expect(readSeparatorKey(keyEvent('ArrowRight'))).toBe('right') + expect(readSeparatorKey(keyEvent('Home'))).toBe('min') + expect(readSeparatorKey(keyEvent('End'))).toBe('max') + }) + + it('ignores other keys, modified presses, and IME composition', () => { + expect(readSeparatorKey(keyEvent('ArrowUp'))).toBeNull() + expect(readSeparatorKey(keyEvent('Enter'))).toBeNull() + for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) { + expect(readSeparatorKey(keyEvent('ArrowLeft', { [modifier]: true }))).toBeNull() + } + expect( + readSeparatorKey(keyEvent('ArrowLeft', { nativeEvent: { isComposing: true } } as never)) + ).toBeNull() + expect(readSeparatorKey(keyEvent('ArrowLeft', { keyCode: 229 }))).toBeNull() + }) +}) diff --git a/apps/sim/lib/core/utils/separator-keys.ts b/apps/sim/lib/core/utils/separator-keys.ts new file mode 100644 index 00000000000..4ebdcc7973e --- /dev/null +++ b/apps/sim/lib/core/utils/separator-keys.ts @@ -0,0 +1,34 @@ +import type { KeyboardEvent } from 'react' + +/** What a key asks of a focused resize separator: move it left or right, or jump to a bound. */ +export type SeparatorKey = 'left' | 'right' | 'min' | 'max' + +/** + * Reads a plain key press on a focused `role='separator'` resize handle. Arrows are the visual + * direction the divider moves in; Home and End are the controlled pane's minimum and maximum + * size. Null for modified or IME-composing presses and every other key. + */ +export function readSeparatorKey(event: KeyboardEvent): SeparatorKey | null { + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.nativeEvent.isComposing || + event.keyCode === 229 + ) { + return null + } + switch (event.key) { + case 'ArrowLeft': + return 'left' + case 'ArrowRight': + return 'right' + case 'Home': + return 'min' + case 'End': + return 'max' + default: + return null + } +} diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts index 52fa4ab4735..7d0501b128f 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -561,6 +561,43 @@ describe('persisted-message', () => { }) describe('stripToolResultOutput', () => { + it('keeps the partial-coverage marker of an empty search through save and reload', () => { + const message: PersistedMessage = { + id: 'msg-search', + role: 'assistant', + content: '', + timestamp: '2026-01-01T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'search', + name: 'search_workspace', + state: 'success', + result: { + success: true, + output: { + success: true, + data: { + query: 'q', + results: [], + retrieval: { status: 'partial', timedOutLegs: ['vector'] }, + }, + }, + }, + }, + }, + ], + } + const reloaded = stripToolResultOutput(stripToolResultOutput(message)) + + expect(reloaded.contentBlocks?.[0].toolCall?.result?.output).toEqual({ + success: true, + data: { results: [], retrieval: { status: 'partial' } }, + }) + }) + it('drops result.output but keeps success and error', () => { const message: PersistedMessage = { id: 'msg-1', diff --git a/apps/sim/lib/mothership/chat/retrieval-citations.test.ts b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts index 777fb37517a..59b8deb4cc1 100644 --- a/apps/sim/lib/mothership/chat/retrieval-citations.test.ts +++ b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts @@ -33,4 +33,21 @@ describe('persisted retrieval citations', () => { }) ).toBeUndefined() }) + it('keeps only a partial-coverage marker, and keeps it through re-compaction', () => { + const compact = compactRetrievalCitations('search_workspace', { + success: true, + data: { results: [], retrieval: { status: 'partial', timedOutLegs: ['vector', 'keyword'] } }, + }) + expect(compact).toEqual({ + success: true, + data: { results: [], retrieval: { status: 'partial' } }, + }) + expect(compactRetrievalCitations('search_workspace', compact)).toEqual(compact) + expect( + compactRetrievalCitations('search_workspace', { + success: true, + data: { results: [], retrieval: { status: 'complete', timedOutLegs: [] } }, + }) + ).toEqual({ success: true, data: { results: [] } }) + }) }) diff --git a/apps/sim/lib/mothership/chat/retrieval-citations.ts b/apps/sim/lib/mothership/chat/retrieval-citations.ts index 0a0d55a38a9..39b320b05ec 100644 --- a/apps/sim/lib/mothership/chat/retrieval-citations.ts +++ b/apps/sim/lib/mothership/chat/retrieval-citations.ts @@ -1,6 +1,9 @@ import { isRecordLike } from '@sim/utils/object' -/** Keeps only bounded display evidence when large retrieval results leave the live stream. */ +/** + * Keeps only bounded display evidence when large retrieval results leave the live stream, + * plus a partial-coverage marker so an empty timed-out search never reads as "No results". + */ export function compactRetrievalCitations(toolName: string, raw: unknown): unknown { if (!['search_workspace', 'read_document'].includes(toolName)) return undefined let output: unknown = raw @@ -43,5 +46,9 @@ export function compactRetrievalCitations(toolName: string, raw: unknown): unkno } return [citation] }) - return { success: true, data: { results: citations } } + const partial = isRecordLike(data.retrieval) && data.retrieval.status === 'partial' + return { + success: true, + data: { results: citations, ...(partial ? { retrieval: { status: 'partial' } } : {}) }, + } }