From d1af604ccf1c5ff89b57c5667926957a89dbccf7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:12:57 -0700 Subject: [PATCH 01/11] fix(desktop): admit browser download saves before reading the body PUT /api/desktop/tool/file buffered up to 100 MiB of the request body before the use case checked that the tool call exists, is a running browser_save_download claimed by the desktop, and belongs to the caller's run, so any signed-in user could force large reads with bogus toolCallIds. Add admitBrowserDownloadSave (same operation and binding resolution as saveBrowserDownload, no claim or audit), following the existing admitCreateWorkspaceFile pattern, and run it before the body read. The save use case still re-validates and claims atomically after the read, so a failed or oversized upload never burns the single-use claim. --- .../app/api/desktop/tool/file/route.test.ts | 23 ++++++- apps/sim/app/api/desktop/tool/file/route.ts | 32 ++++++--- .../application/browser-file-transfer.test.ts | 23 +++++++ .../application/browser-file-transfer.ts | 65 +++++++++++++------ 4 files changed, 114 insertions(+), 29 deletions(-) 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/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( From 7107ca43855ed76a1bcbfef93614b6b1a5a27fe4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:09:02 -0700 Subject: [PATCH 02/11] fix(desktop): walk cross-realm shadow roots when resolving upload targets resolveFileInputTarget climbed out of shadow roots with `root instanceof ShadowRoot`. Elements in same-origin iframes belong to the frame's realm, so the check was false for their shadow roots and the ancestor walk stopped at the shadow boundary, failing browser_upload_file with "no nearby file input". Use the file's duck-typed `'host' in root` idiom like every other shadow-host hop. --- .../src/main/browser-agent/page-functions.test.ts | 15 +++++++++++++++ .../src/main/browser-agent/page-functions.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) 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.') From e4ad8e751239bfbdd343a6bafea42605c800a750 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:10:17 -0700 Subject: [PATCH 03/11] fix(chat): keep an activity's completed title off runs while it still waits on the user Every run of a main-lane activity segment received the segment-wide activity, including its completedTitle. A finished multi-call run before a pending approval or terminal handoff (which splits the segment into runs) therefore read the past-tense completed title while the activity was still unfinished. Runs now receive the completed title only once every call in the segment has finished; until then they keep the activity's in-progress title and summarize their own calls. Fully finished activities render exactly as before. --- .../agent-group/agent-group.test.ts | 44 +++++++++++++++++++ .../agent-group/main-agent-activity.tsx | 11 ++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts index ba57fbbde34..f11bf49e527 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.test.ts @@ -481,6 +481,50 @@ describe('AgentGroup inline main activity', () => { 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} From 16549c2b5feac490a71b51dcec096b0c2e7141c4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:25:52 -0700 Subject: [PATCH 04/11] fix(search): keep partial coverage through persisted search compaction compactRetrievalCitations kept only citations, dropping data.retrieval, and stripToolResultOutput applies it on both save and load. A reloaded timed-out search with no matches therefore rendered "No results", which the live UI and the tool itself deliberately avoid because partial results cannot establish absence. Compaction now keeps a bounded retrieval: { status: 'partial' } marker (never the full retrieval object), and re-compacting compacted output keeps it. Complete searches compact exactly as before. --- .../agent-group/search-activity.test.tsx | 21 +++++++++++ .../mothership/chat/persisted-message.test.ts | 37 +++++++++++++++++++ .../chat/retrieval-citations.test.ts | 17 +++++++++ .../mothership/chat/retrieval-citations.ts | 11 +++++- 4 files changed, 84 insertions(+), 2 deletions(-) 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/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' } } : {}) }, + } } From 5a3a512bb6604f95c18ad7224dac07be11059a13 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:09:27 -0700 Subject: [PATCH 05/11] fix(organization): route chat URLs the same way as organization Home The chat page gated on (mothershipAvailable || memberScoped) while OrganizationHome renders nothing without copilot.use, or without Build and Search. A viewer with copilot.use but neither Build nor Search, and a Search-only viewer opening an assistant chat, got a blank page. Apply Home's two redirects (Search when copilot.use is denied, workspace settings when neither Build nor Search is allowed) before loading the chat, through one getOrganizationHomeRedirect shared by both pages so they cannot drift again; the later copilot.use redirect becomes unreachable and is removed. --- .../o/[organizationId]/chat/[chatId]/page.tsx | 6 +++--- .../o/[organizationId]/home/home-redirect.ts | 20 +++++++++++++++++++ .../app/o/[organizationId]/home/page.test.tsx | 18 +++++++++++++++++ apps/sim/app/o/[organizationId]/home/page.tsx | 8 +++----- 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/home/home-redirect.ts 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/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 ( }> From 1fe12ef6cca09ab1dad84cd8a7882eecb4061e67 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:12:43 -0700 Subject: [PATCH 06/11] fix(organization): keep a custom date range when Home restores Search results organizationHomeParsers already reads from/to, but OrganizationHomeContent passed only source and updated to searchFiltersFromParams, so a Home URL with updated=custom restored an unbounded search. Pass from/to through, matching the workspace results view, and add them to the effect deps. --- .../home/organization-home.test.tsx | 17 +++++++++++++++++ .../[organizationId]/home/organization-home.tsx | 10 +++++----- 2 files changed, 22 insertions(+), 5 deletions(-) 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..b38c073f916 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -892,6 +892,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..80e444a01ee 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -84,10 +84,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') @@ -143,7 +141,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 +154,8 @@ function OrganizationHomeContent({ q, source, updated, + from, + to, organization.id, controller.activeResourceParam, chat.resources, From 01831dda8517a0f1ab6665ed7f321b94f241feb7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:18:46 -0700 Subject: [PATCH 07/11] fix(chat): project desktop tabs in organization Assistant chats Organization Home passed projectsDesktopTabs: false to useChat in Assistant mode, which only nulls the native active-tab ids used for the strip's fallback. ChatResourcePanel still projects the chat scope's browser and terminal tabs in every mode, and chat link clicks in the desktop app open those tabs through the same projection, so Assistant chats showed desktop tabs while ignoring which one the desktop app remembers (the #7793 reopen behavior). Build Home's options with getMothershipUseChatOptions, like workspace Home, so tabs project consistently in every mode. Assistant requests still attach no resources. --- .../home/organization-home.test.tsx | 7 +++++-- .../home/organization-home.tsx | 20 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) 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 b38c073f916..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 () => { diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 80e444a01ee..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, @@ -102,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 From d7d4afe9ed874cb932715e7a30eafe2f9726f8c0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:19:43 -0700 Subject: [PATCH 08/11] fix(home): make the chat/resource panel divider keyboard-adjustable The resource panel divider (moved into the shared ChatPanelLayout this release, identical to main's home.tsx) was pointer-only: a separator with no tabIndex, key handling, or aria-value*, so keyboard users could not focus or resize it. Mirror the file text-editor split: the divider is now a focusable separator with ArrowLeft/ArrowRight steps and Home/End. Both separators now read keys through one readSeparatorKey helper (modifier and IME guard included). Keyboard widths go through the same MIN/max clamps as the drag (keyboardPanelWidth next to panelWidthAt), land without the width transition like the window-resize clamp (shared writeWidthInstantly), are ignored during a live drag, and claim the resource view like a pointer resize. aria-valuenow/min/ max are written imperatively on focus, key, and drag end, preserving the hook's zero-render resize design. Focus-visible outline matches the text-editor split. --- .../components/file-viewer/text-editor.tsx | 21 +- .../components/chat-panel-layout.test.tsx | 38 ++++ .../home/components/chat-panel-layout.tsx | 18 +- .../home/components/chat-resource-panel.tsx | 4 + .../home/hooks/use-mothership-resize.test.ts | 106 --------- .../home/hooks/use-mothership-resize.test.tsx | 214 ++++++++++++++++++ .../home/hooks/use-mothership-resize.ts | 78 ++++++- .../home/hooks/use-resource-panel.test.tsx | 24 +- .../home/hooks/use-resource-panel.ts | 19 +- .../sim/lib/core/utils/separator-keys.test.ts | 40 ++++ apps/sim/lib/core/utils/separator-keys.ts | 34 +++ 11 files changed, 463 insertions(+), 133 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.tsx create mode 100644 apps/sim/lib/core/utils/separator-keys.test.ts create mode 100644 apps/sim/lib/core/utils/separator-keys.ts 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={ = {}): 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..7ece66d9289 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-mothership-resize.test.tsx @@ -0,0 +1,214 @@ +/** + * @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('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..2a796c957e7 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,6 +111,8 @@ 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) { @@ -148,6 +190,7 @@ export function useMothershipResize(desktopScopeId: string) { document.body.style.cursor = '' document.body.style.userSelect = '' cleanupRef.current = null + syncDividerValue(handle, el) } cleanupRef.current = cleanup @@ -217,13 +260,7 @@ export function useMothershipResize(desktopScopeId: string) { if (!el || !pinned) 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 + writeWidthInstantly(el, maxWidth) } const handleWindowResize = () => { @@ -238,10 +275,35 @@ 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. */ + const handleResizeFocus = useCallback((e: React.FocusEvent) => { + 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/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 + } +} From 36e70b651e6bc1571b2eccaa39420d5a8672de06 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:08:17 -0700 Subject: [PATCH 09/11] docs(rules): scope member avatar aria-hidden to rows that show the name The settings-pages rule said every member avatar renders aria-hidden because the name is always beside it. MemberRow shows only the email, so its avatar correctly stays labelled (role=img, aria-label=name), matching the emcn Avatar TSDoc. Reword the rule and regenerate the Cursor projection. --- .claude/rules/sim-settings-pages.md | 11 ++++++----- .cursor/rules/sim-settings-pages.mdc | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) 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 From c2e05c4779f18f2337b193d4e235cc933a5726b6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:08:34 -0700 Subject: [PATCH 10/11] docs(skills): clarify service-mode selector guidance in add-connector The skill said service mode requires shared selectors, but GitLab (service mode only) uses plain host/project inputs. Align with the live-search README: verification is mandatory; shared selectors apply only to resource pickers. --- .agents/skills/add-connector/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 306d80a99fd472511daab6a4e1a4586240af5f53 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 24 Sep 2026 15:52:28 -0700 Subject: [PATCH 11/11] fix(home): keep a focused divider's value current through window resizes The window-resize clamp updated the panel width but not the focused divider's aria-valuemax/valuenow, so assistive tech kept the old bounds until the divider was focused again. The clamp now also reports to the divider while it holds focus, including when the pinned width stays within the new bounds. --- .../home/hooks/use-mothership-resize.test.tsx | 25 +++++++++++++++++++ .../home/hooks/use-mothership-resize.ts | 15 +++++++---- 2 files changed, 35 insertions(+), 5 deletions(-) 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 index 7ece66d9289..942af4b38d2 100644 --- 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 @@ -198,6 +198,31 @@ describe('useMothershipResize keyboard divider', () => { 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 [ 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 2a796c957e7..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 @@ -118,6 +118,7 @@ function syncDividerValue(handle: HTMLElement, el: HTMLElement, maxWidth = measu 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 @@ -256,11 +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 - writeWidthInstantly(el, maxWidth) + if (pinned && Number.parseFloat(pinned) > maxWidth) writeWidthInstantly(el, maxWidth) + if (reportsToDivider) syncDividerValue(divider, el, maxWidth) } const handleWindowResize = () => { @@ -288,8 +292,9 @@ export function useMothershipResize(desktopScopeId: string) { syncDividerValue(e.currentTarget, el, maxWidth) }, []) - /** Reports the current width when the divider takes focus. */ + /** 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) }, [])