diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index c9d4f92a539..e3ed8a6a745 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -11,6 +11,14 @@ on: - '.github/workflows/desktop-release.yml' - 'apps/desktop/**' - 'apps/sim/app/_shell/desktop-update-*.tsx' + - 'apps/sim/app/workspace/**/home/hooks/use-mothership-resize.ts' + - 'apps/sim/app/workspace/**/home/hooks/use-resource-panel.ts' + - 'apps/sim/app/workspace/**/home/components/chat-panel-layout.tsx' + - 'apps/sim/stores/chat-panel/**' + - 'apps/sim/stores/constants.ts' + - 'apps/sim/lib/browser-agent/transport.ts' + - 'apps/sim/lib/core/utils/separator-keys.ts' + - 'apps/sim/scripts/fixtures/chat-panel.tsx' - 'apps/sim/app/layout.tsx' - 'apps/sim/hooks/use-desktop-update-state.ts' - 'apps/sim/lib/desktop/**' diff --git a/apps/desktop/e2e/chat-panel.spec.ts b/apps/desktop/e2e/chat-panel.spec.ts new file mode 100644 index 00000000000..920be2ce387 --- /dev/null +++ b/apps/desktop/e2e/chat-panel.spec.ts @@ -0,0 +1,429 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { _electron as electron, expect, test } from '@playwright/test' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { getErrorMessage } from '@sim/utils/errors' +import { build } from 'esbuild' +import postcss from 'postcss' +import loadPostcssConfig from 'postcss-load-config' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../sim/scripts/fixtures/chat-panel.tsx', import.meta.url)) + +test('chat panel sizes survive navigation, chat switches, collapse, and layout constraints', async () => { + const reportPath = process.env.CHAT_PANEL_REPORT_PATH ?? test.info().outputPath('chat-panel.json') + const checks: { + name: string + status: 'passed' | 'failed' + durationMs: number + error?: string + }[] = [] + const check = async (name: string, run: () => Promise) => { + const started = Date.now() + try { + await test.step(name, run) + checks.push({ name, status: 'passed', durationMs: Date.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: Date.now() - started, + error: getErrorMessage(error), + }) + throw error + } + } + const userData = mkdtempSync(join(tmpdir(), 'sim-chat-panel-e2e-')) + let app: Awaited> | undefined + const errors: string[] = [] + let desktopExit: { code: number | null; signal: string | null } | null = null + let rendererCrashed = false + let passed = false + let javascript = '' + let stylesheet = '' + const server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/page') { + response.setHeader('Content-Type', 'text/html') + response.end('Native browser resize fixture') + } else if (path === '/fixture.js' || path === '/fixture.css') { + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') + response.end(path.endsWith('.js') ? javascript : stylesheet) + } else if (path.startsWith('/api/')) { + response.setHeader('Content-Type', 'application/json') + response.end( + path === '/api/auth/get-session' + ? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) + : '{}' + ) + } else { + response.setHeader('Content-Type', 'text/html') + response.setHeader( + 'Set-Cookie', + 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/' + ) + response.end( + '
' + ) + } + }) + + try { + await check('load the production resource panel resize hook in Electron', async () => { + const config = await loadPostcssConfig({}, SIM_DIR) + const cssPath = join(SIM_DIR, 'app/_styles/globals.css') + const css = await postcss(config.plugins).process( + `${readFileSync(cssPath, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, + { from: cssPath } + ) + const bundle = await build({ + entryPoints: [FIXTURE], + bundle: true, + write: false, + outfile: test.info().outputPath('fixture.js'), + external: ['node:async_hooks'], + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, + format: 'iife', + platform: 'browser', + tsconfig: join(SIM_DIR, 'tsconfig.json'), + define: { 'process.env.NODE_ENV': '"development"' }, + }) + javascript = bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text ?? '' + stylesheet = `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` + await new Promise((resolve) => server.listen(0, resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`, + SIM_DESKTOP_USER_DATA: userData, + }, + }) + }) + if (!app) throw new Error('Electron did not launch') + app.process().once('exit', (code, signal) => { + desktopExit = { code, signal } + }) + const shell = app + const page = await shell.firstWindow() + page.on('pageerror', (error) => errors.push(error.message)) + page.on('crash', () => { + rendererCrashed = true + }) + await shell.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + // Keep the physical window inside the small displays used by macOS CI. + window.setMinimumSize(0, 0) + window.setContentSize(720, 400) + window.webContents.setBackgroundThrottling(false) + app.focus({ steal: true }) + window.focus() + }) + await page.reload() + await shell.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + window.webContents.setZoomFactor(0.5) + app.focus({ steal: true }) + window.focus() + }) + expect(errors).toEqual([]) + await expect + .poll(() => + shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused()) + ) + .toBe(true) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(1440) + const panel = page.locator('[data-mothership-panel]') + const divider = page.getByRole('separator', { name: 'Resize resource view' }) + const width = () => panel.evaluate((element) => element.getBoundingClientRect().width) + const expectWidth = async (expected: number) => { + await expect.poll(width).toBeCloseTo(expected, 0) + } + const beginDrag = async () => { + await shell.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }) + BrowserWindow.getAllWindows()[0].focus() + }) + await expect + .poll(() => + shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused()) + ) + .toBe(true) + await divider.hover({ position: { x: 4, y: 100 } }) + const rect = await panel.boundingBox() + if (!rect) throw new Error('Missing panel bounds') + await page.mouse.down() + await expect + .poll(() => divider.evaluate((element) => element.hasPointerCapture(1))) + .toBe(true) + return rect + } + const dragTo = async (target: number) => { + const rect = await beginDrag() + await page.mouse.move(rect.x + rect.width - target, rect.y + 100, { steps: 12 }) + await page.mouse.up() + await expectWidth(target) + } + + await check('a chosen width survives a settings round trip and reload', async () => { + await expectWidth(720) + await dragTo(620) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect(panel).toHaveCount(0) + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expectWidth(620) + await page.reload() + await expectWidth(620) + }) + + await check( + 'workspace and organization chats restore independent widths without remounting', + async () => { + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(720) + await dragTo(830) + await page.getByRole('button', { name: 'organization-chat-a', exact: true }).click() + await expectWidth(720) + await dragTo(560) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expectWidth(560) + await page.getByRole('button', { name: 'workspace-chat-a', exact: true }).click() + await expectWidth(620) + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(830) + } + ) + + await check( + 'collapse preserves the expanded preference, including keyboard changes', + async () => { + await page.getByRole('button', { name: 'Collapse resource view' }).click() + await expectWidth(0) + await page.getByRole('button', { name: 'Expand resource view' }).click() + await expectWidth(830) + await divider.focus() + await page.keyboard.press('ArrowRight') + await expectWidth(798) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expectWidth(798) + } + ) + + await check('container and window clamps do not overwrite the preferred width', async () => { + await page.getByRole('button', { name: 'Resize container' }).click() + await expectWidth(520) + await page.getByRole('button', { name: 'Resize container' }).click() + await expectWidth(798) + await shell.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(525, 400) + ) + await expectWidth(570) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Back', exact: true }).click() + await expectWidth(570) + await shell.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(720, 400) + ) + await expectWidth(798) + }) + + await check('resizing writes storage only when the gesture ends', async () => { + const before = await page.evaluate(() => JSON.stringify(localStorage)) + const rect = await beginDrag() + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await expectWidth(698) + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) + await page.mouse.up() + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toBe(before) + }) + + for (const interruption of [ + 'pointercancel', + 'capture loss', + 'blur', + 'detach', + 'chat switch', + ] as const) { + await check(`${interruption} keeps the previous saved width`, async () => { + const before = await page.evaluate(() => JSON.stringify(localStorage)) + const rect = await beginDrag() + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await expectWidth(598) + if (interruption === 'pointercancel') { + await divider.dispatchEvent('pointercancel', { pointerId: 1 }) + } else if (interruption === 'capture loss') { + await divider.evaluate((element) => element.releasePointerCapture(1)) + await page.mouse.move(rect.x + 101, rect.y + 100) + } else if (interruption === 'blur') { + await page.evaluate(() => window.dispatchEvent(new Event('blur'))) + } else if (interruption === 'chat switch') { + await page + .getByRole('button', { name: 'organization-chat-a', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expectWidth(560) + } else { + await page + .getByRole('button', { name: 'Settings', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expect(panel).toHaveCount(0) + } + await page.mouse.up() + if (interruption === 'chat switch') { + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + } + if (interruption === 'detach') { + await page.getByRole('button', { name: 'Back', exact: true }).click() + } + await expectWidth(698) + expect(await page.evaluate(() => JSON.stringify(localStorage))).toBe(before) + }) + } + + await check('a viewport change during a drag clamps the committed display width', async () => { + await page.getByRole('button', { name: 'Resize container' }).click() + await expectWidth(520) + const rect = await beginDrag() + await page.mouse.move(rect.x + 20, rect.y + 100, { steps: 12 }) + await expectWidth(500) + await shell.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(300, 400) + ) + await expect.poll(() => page.evaluate(() => window.innerWidth)).toBe(600) + await page.mouse.up() + await expectWidth(480) + await shell.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(720, 400) + ) + await expectWidth(500) + await page.getByRole('button', { name: 'Resize container' }).click() + await dragTo(698) + }) + + await check('another account cannot inherit the current chat width', async () => { + await page.getByRole('button', { name: 'Switch account' }).click() + await expectWidth(720) + await dragTo(580) + await page.getByRole('button', { name: 'Switch account' }).click() + await expectWidth(698) + }) + await check('assigning a permanent chat ID lets an active drag finish', async () => { + await page.getByRole('button', { name: 'pending:chat', exact: true }).click() + await dragTo(620) + const rect = await beginDrag() + await page.mouse.move(rect.x + 100, rect.y + 100, { steps: 12 }) + await expectWidth(520) + await page + .getByRole('button', { name: 'Assign chat ID', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expect(page.getByRole('button', { name: 'Assign chat ID', exact: true })).toBeDisabled() + expect(await divider.evaluate((element) => element.hasPointerCapture(1))).toBe(true) + await page.mouse.up() + await expectWidth(520) + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(698) + await page.getByRole('button', { name: 'assigned-chat', exact: true }).click() + await expectWidth(520) + await page.getByRole('button', { name: 'workspace-chat-b', exact: true }).click() + await expectWidth(698) + }) + + await check( + 'cancelling before the first animation frame restores native browser bounds', + async () => { + await page.getByRole('button', { name: 'pending:native', exact: true }).click() + await dragTo(698) + await shell.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }) + BrowserWindow.getAllWindows()[0].focus() + }) + await expect + .poll(() => + shell.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].isFocused()) + ) + .toBe(true) + await page.getByRole('button', { name: 'Start browser', exact: true }).click() + const nativeBounds = () => + shell.evaluate(({ BrowserWindow, WebContentsView }) => { + const view = BrowserWindow.getAllWindows()[0].contentView.children.find( + (child) => + child instanceof WebContentsView && child.webContents.getURL().endsWith('/page') + ) + return view?.getVisible() ? view.getBounds() : null + }) + await expect.poll(nativeBounds).not.toBeNull() + const before = await nativeBounds() + await beginDrag() + await divider.evaluate((element) => { + const rect = element.getBoundingClientRect() + element.dispatchEvent( + new PointerEvent('pointermove', { pointerId: 1, clientX: rect.x + 104, bubbles: true }) + ) + element.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 1, bubbles: true })) + }) + // The bridge round trip observes main-process geometry after the queued bounds messages. + await page.evaluate(() => + ( + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } + ).simDesktop.browserAgent.capturePanelSnapshot('pending:native') + ) + await page.mouse.up() + await expectWidth(698) + expect(await nativeBounds()).toEqual(before) + + await check('native predictions follow a chat ID assigned during a drag', async () => { + if (!before) throw new Error('Missing native browser bounds') + await beginDrag() + await page + .getByRole('button', { name: 'Assign chat ID', exact: true }) + .evaluate((element: HTMLButtonElement) => element.click()) + await expect( + page.getByRole('button', { name: 'Assign chat ID', exact: true }) + ).toBeDisabled() + expect(await divider.evaluate((element) => element.hasPointerCapture(1))).toBe(true) + await divider.evaluate((element) => { + const rect = element.getBoundingClientRect() + element.dispatchEvent( + new PointerEvent('pointermove', { + pointerId: 1, + clientX: rect.x + 104, + bubbles: true, + }) + ) + }) + await page.evaluate(() => + ( + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } + ).simDesktop.browserAgent.capturePanelSnapshot('assigned-chat') + ) + expect(await nativeBounds()).toEqual({ + ...before, + x: before.x + 50, + width: before.width - 50, + }) + await page.mouse.up() + await expectWidth(598) + }) + } + ) + expect(errors).toEqual([]) + passed = true + } finally { + mkdirSync(dirname(reportPath), { recursive: true }) + writeFileSync( + reportPath, + JSON.stringify({ passed, checks, errors, desktopExit, rendererCrashed }, null, 2) + ) + await app?.close() + await new Promise((resolve) => server.close(() => resolve())) + rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx index 90d272b5883..3c18b5fb2e3 100644 --- a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx @@ -3,8 +3,8 @@ import { useEffect, useState } from 'react' import { cn } from '@sim/emcn' import { ArrowRight } from '@sim/emcn/icons' -import Link from 'next/link' import { HomeSection } from '@/components/home/home-section' +import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' @@ -142,7 +142,11 @@ export function GetStarted() { {steps.map((step, i) => { const complete = completed[step.id] return ( - 0 && 'border-t')}> + 0 && 'border-t')} + > - + ) })} diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 7baabe357df..e6d7d080c28 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -141,7 +141,7 @@ function OrganizationHomeContent({ !hasChat && mothershipAvailable && canBuild && (searchAccess.memberScoped || planEnabled) const liveSearch = getDeploymentShape().features.liveEnterpriseSearch === true const assistantSearchLevel = 'fast' - const panel = useChatResourcePanel(chat, controller) + const panel = useChatResourcePanel(chat, controller, userId) const addResource = panel.addResourceFromUser /** Restore only an explicitly selected results tab on an empty Home; closing it clears the URL. */ useEffect(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx index 4ff7e8bf0f6..3e18dde09d6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx @@ -1,6 +1,7 @@ 'use client' import { createContext, type ReactNode, useContext } from 'react' +import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' import { ApiClientError } from '@/lib/api/client/errors' import { useSession } from '@/lib/auth/auth-client' import { organizationRoutes } from '@/lib/navigation/paths' @@ -75,12 +76,12 @@ export function OrganizationSecretInputHost({ {organizationContext.viewer.isAdmin ? ( <> Enable Generic Secrets in{' '} - organization Integrations settings - + , then return here to enter the keys. ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 3665d705d12..fd03be2d91a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -53,6 +53,7 @@ import { parseSearchConnectionBody, searchConnectionTargetSchema, } from '@/lib/knowledge/search/connection-target' +import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { organizationSecretNameSchema } from '@/lib/organization-secrets/validation' @@ -3488,6 +3489,7 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {canManageBilling ? ( rememberSettingsReturnUrl(href)} variant='border' rightIcon={hosted ? ArrowRight : SquareArrowUpRight} target={hosted ? undefined : '_blank'} diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index a41c43e3d8c..c7b50e9ec2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -178,7 +178,7 @@ function HomeContent({ chatId, userName, userId }: HomeProps) { dispatchingHeadId, getCurrentRequestId, } = chat - const panel = useChatResourcePanel(chat, controller) + const panel = useChatResourcePanel(chat, controller, userId) const { isResourceCollapsed, skipResourceTransition, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 805b8fb6604..bb40fe65b80 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -126,6 +126,7 @@ import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-ca import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' import { workflowKeys } from '@/hooks/queries/workflows' import { snapAllSmoothText } from '@/hooks/use-smooth-text' +import { useChatPanelStore } from '@/stores/chat-panel/store' import { useMothershipEffortStore } from '@/stores/mothership-effort/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { @@ -1183,6 +1184,9 @@ export function useChat( : pendingChatKeyRef.current chatIdRef.current = chatId const resolvedDesktopScopeId = desktopChatScopeId(scopeKey, chatId) + if (wasPending) { + useChatPanelStore.getState().migrate(pendingDesktopScopeId, resolvedDesktopScopeId) + } const activeActivityTracker = resourceActivityTrackerRef.current if (activeActivityTracker?.generation === streamGenRef.current) { if (wasPending) { @@ -1753,6 +1757,7 @@ export function useChat( return } + useChatPanelStore.getState().migrate(previousDesktopScopeId, resolvedChatId) await migrateDesktopChatScopes(previousDesktopScopeId, resolvedChatId) if (pendingChatKey) { useMothershipQueueStore.getState().migrate(pendingChatKey, resolvedChatId) 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 f9e543a4f8e..53d4587176c 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,6 +1,7 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' import { beginBrowserPanelDividerDrag } from '@/lib/browser-agent/transport' import { readSeparatorKey, type SeparatorKey } from '@/lib/core/utils/separator-keys' +import { useChatPanelStore } from '@/stores/chat-panel/store' import { MOTHERSHIP_WIDTH } from '@/stores/constants' /** @@ -97,6 +98,16 @@ function writeWidthInstantly(el: HTMLElement, width: number) { el.style.transition = prevTransition } +/** Restores the preference within the current layout without changing the saved width. */ +function restorePanelWidth(el: HTMLElement, preferred: number | undefined) { + if (preferred === undefined) { + el.style.removeProperty('width') + return + } + const width = Math.min(preferred, measureMaxWidth(el)) + if (el.style.width !== `${width}px`) writeWidthInstantly(el, width) +} + /** 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)) @@ -104,6 +115,17 @@ function syncDividerValue(handle: HTMLElement, el: HTMLElement, maxWidth = measu handle.setAttribute('aria-valuenow', String(Math.round(el.getBoundingClientRect().width))) } +/** Synchronous storage hydration also covers panels that arrive after a lazy fallback. */ +function readPreferredWidth(userId: string | undefined, scopeId: string): number | undefined { + if (!useChatPanelStore.persist.hasHydrated()) void useChatPanelStore.persist.rehydrate() + return userId ? useChatPanelStore.getState().widths[`${userId}:${scopeId}`] : undefined +} + +interface MothershipResizeOptions { + userId?: string + collapsed: boolean +} + /** * Hook for managing resize of the MothershipView resource panel. * @@ -113,184 +135,209 @@ function syncDividerValue(handle: HTMLElement, el: HTMLElement, maxWidth = measu * `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) { +export function useMothershipResize( + desktopScopeId: string, + { userId, collapsed }: MothershipResizeOptions +) { + const scopeRef = useRef(desktopScopeId) const mothershipRef = useRef(null) const cleanupRef = useRef<(() => void) | null>(null) const focusedDividerRef = useRef(null) - const desktopScopeIdRef = useRef(desktopScopeId) - desktopScopeIdRef.current = desktopScopeId - - const handleResizePointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault() + const preferredWidthRef = useRef(undefined) + + const rememberWidth = useCallback( + (width: number) => { + preferredWidthRef.current = width + if (userId) useChatPanelStore.getState().setWidth(userId, desktopScopeId, width) + }, + [userId, desktopScopeId] + ) + const restoreWidth = useCallback(() => { const el = mothershipRef.current - if (!el) return - // Single-flight: a second press while a drag is live must not stack listeners - if (cleanupRef.current) return - - const handle = e.currentTarget as HTMLElement - const pointerId = e.pointerId - handle.setPointerCapture(pointerId) - - // Pin to current rendered width so drag starts from the visual position - const startRect = el.getBoundingClientRect() - el.style.width = `${startRect.width}px` - - // Nothing moves the panel's right edge mid-drag, and the pointer keeps the - // offset it grabbed at, so one measurement serves the whole gesture. - const geometry: DragGeometry = { - panelRight: startRect.right, - grabOffset: e.clientX - startRect.left, - maxWidth: measureMaxWidth(el), - } - - // The panel's left edge IS the divider. Handing it to the browser - // transport lets the native browser view (when one is showing) be - // repositioned arithmetically per pointer move instead of waiting for the - // renderer's layout → measure → report round-trip; no-op (null) when no - // browser resource is live - const predictBrowserBounds = beginBrowserPanelDividerDrag( - startRect.left, - desktopScopeIdRef.current - ) + if (!el || cleanupRef.current) return + restorePanelWidth(el, collapsed ? undefined : preferredWidthRef.current) + const divider = focusedDividerRef.current + if (divider && document.activeElement === divider) syncDividerValue(divider, el) + }, [collapsed]) + + useLayoutEffect(() => { + const store = useChatPanelStore.getState() + if (store.resolveChatId(scopeRef.current) !== desktopScopeId) cleanupRef.current?.() + scopeRef.current = desktopScopeId + preferredWidthRef.current = readPreferredWidth(userId, desktopScopeId) + restoreWidth() + }, [desktopScopeId, userId, restoreWidth]) + + /** DOM attachment owns gesture cleanup; pending chat adoption leaves the same panel attached. */ + const attachPanel = useCallback( + (el: HTMLDivElement | null) => { + if (!el) return + mothershipRef.current = el + preferredWidthRef.current = readPreferredWidth(userId, scopeRef.current) + let rafId: number | null = null + const scheduleRestore = () => { + rafId ??= requestAnimationFrame(() => { + rafId = null + restoreWidth() + }) + } + restoreWidth() + const observer = new ResizeObserver(scheduleRestore) + if (el.parentElement) observer.observe(el.parentElement) + window.addEventListener('resize', scheduleRestore) + return () => { + cleanupRef.current?.() + observer.disconnect() + window.removeEventListener('resize', scheduleRestore) + if (rafId !== null) cancelAnimationFrame(rafId) + mothershipRef.current = null + } + }, + [userId, restoreWidth] + ) - // Disable CSS transition to prevent animation lag during drag - const prevTransition = el.style.transition - el.style.transition = 'none' - document.body.style.cursor = 'ew-resize' - document.body.style.userSelect = 'none' + const handleResizePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault() - let rafId: number | null = null - let lastClientX: number | null = null + const el = mothershipRef.current + if (!el) return + // Single-flight: a second press while a drag is live must not stack listeners + if (cleanupRef.current) return + + const handle = e.currentTarget as HTMLElement + const pointerId = e.pointerId + handle.setPointerCapture(pointerId) + + // Pin to current rendered width so drag starts from the visual position + const startRect = el.getBoundingClientRect() + el.style.width = `${startRect.width}px` + + // Snapshot geometry avoids layout reads on every move; release applies fresh bounds. + const geometry: DragGeometry = { + panelRight: startRect.right, + grabOffset: e.clientX - startRect.left, + maxWidth: measureMaxWidth(el), + } - const applyWidth = (clientX: number) => { - el.style.width = `${panelWidthAt(clientX, geometry)}px` - } + // The panel's left edge IS the divider. Handing it to the browser + // transport lets the native browser view (when one is showing) be + // repositioned arithmetically per pointer move instead of waiting for the + // renderer's layout → measure → report round-trip; no-op (null) when no + // browser resource is live + const predictBrowserBounds = beginBrowserPanelDividerDrag(startRect.left, desktopScopeId) - // AbortController removes all listeners at once on cleanup/cancel/unmount - const ac = new AbortController() - const { signal } = ac + // Disable CSS transition to prevent animation lag during drag + const prevTransition = el.style.transition + el.style.transition = 'none' + document.body.style.cursor = 'ew-resize' + document.body.style.userSelect = 'none' - const cleanup = () => { - ac.abort() - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null - } - // Land on the exact final pointer position before transitions come back, - // so a fast flick whose last move never got a frame is not lost. The - // flush is what stops that catch-up delta from animating: without it the - // width write and the transition restore land in one style change, and - // the panel eases into its final width over 200ms while the native view - // chases it. - if (lastClientX !== null) applyWidth(lastClientX) - void el.offsetWidth - el.style.transition = prevTransition - document.body.style.cursor = '' - document.body.style.userSelect = '' - cleanupRef.current = null - syncDividerValue(handle, el) - } - cleanupRef.current = cleanup - - handle.addEventListener( - 'pointermove', - (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return - lastClientX = moveEvent.clientX - // Fast path first: hand the native browser view its next rect at - // pointer-event time (clamped exactly like the width write below), a - // full layout pass ahead of the measured geometry report - predictBrowserBounds?.(dividerXAt(moveEvent.clientX, geometry)) - // Coalesce to one width write per frame: pointermove can outpace the - // display refresh, and every unbatched write forces an extra layout - // pass that the embedded browser view then has to chase - rafId ??= requestAnimationFrame(() => { - rafId = null - if (lastClientX !== null) applyWidth(lastClientX) - }) - }, - { signal } - ) + let rafId: number | null = null + let lastClientX: number | null = null - handle.addEventListener( - 'pointerup', - (upEvent: PointerEvent) => { - if (upEvent.pointerId !== pointerId) return - handle.releasePointerCapture(upEvent.pointerId) - cleanup() - }, - { signal } - ) + const applyWidth = (clientX: number) => { + el.style.width = `${panelWidthAt(clientX, geometry)}px` + } - // Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.) - // Without this, body cursor/userSelect and transition would be permanently stuck - handle.addEventListener('pointercancel', cleanup, { signal }) - // A blur mid-drag (cmd-tab, window switch) would otherwise strand the - // body cursor/userSelect overrides with no pointerup coming - window.addEventListener('blur', cleanup, { signal }) - }, []) + // AbortController removes all listeners at once on cleanup/cancel/unmount + const ac = new AbortController() + const { signal } = ac - // Tear down any active drag if the component unmounts mid-drag - useEffect(() => { - return () => { - cleanupRef.current?.() - } - }, []) + const finish = (commit: boolean) => { + ac.abort() + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + if (commit && lastClientX !== null) { + rememberWidth(panelWidthAt(lastClientX, geometry)) + } + // Flush the restored width before transitions return, so the native view + // does not chase a 200ms catch-up animation. + restorePanelWidth(el, preferredWidthRef.current) + void el.offsetWidth + // A cancelled frame may never change DOM size, so ResizeObserver cannot undo its prediction. + const restoredRect = el.getBoundingClientRect() + if ( + restoredRect.left === startRect.left && + restoredRect.top === startRect.top && + restoredRect.width === startRect.width && + restoredRect.height === startRect.height + ) { + predictBrowserBounds?.(restoredRect.left, scopeRef.current) + } + el.style.transition = prevTransition + document.body.style.cursor = '' + document.body.style.userSelect = '' + cleanupRef.current = null + if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId) + syncDividerValue(handle, el) + } + const cancel = () => finish(false) + const cancelPointer = (event: PointerEvent) => { + if (event.pointerId === pointerId) cancel() + } + cleanupRef.current = cancel + + handle.addEventListener( + 'pointermove', + (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== pointerId) return + lastClientX = moveEvent.clientX + // Fast path first: hand the native browser view its next rect at + // pointer-event time (clamped exactly like the width write below), a + // full layout pass ahead of the measured geometry report + predictBrowserBounds?.(dividerXAt(moveEvent.clientX, geometry), scopeRef.current) + // Coalesce to one width write per frame: pointermove can outpace the + // display refresh, and every unbatched write forces an extra layout + // pass that the embedded browser view then has to chase + rafId ??= requestAnimationFrame(() => { + rafId = null + if (lastClientX !== null) applyWidth(lastClientX) + }) + }, + { signal } + ) + + handle.addEventListener( + 'pointerup', + (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) return + finish(true) + }, + { signal } + ) + + // Browser fires pointercancel when it reclaims the gesture (scroll, palm rejection, etc.) + // Without this, body cursor/userSelect and transition would be permanently stuck + handle.addEventListener('pointercancel', cancelPointer, { signal }) + handle.addEventListener('lostpointercapture', cancelPointer, { signal }) + // A blur mid-drag (cmd-tab, window switch) would otherwise strand the + // body cursor/userSelect overrides with no pointerup coming + window.addEventListener('blur', cancel, { signal }) + }, + [desktopScopeId, rememberWidth] + ) - // Re-clamp panel width when the viewport is resized (inline px width can exceed max after narrowing). - // Shares `measureMaxWidth` with the drag so a window resize can never leave - // the panel at a width the drag would have refused, or vice versa. - // Coalesced to one frame, and the pinned width is read off the inline style - // rather than the box: resize events can outpace the display during a live - // window-edge drag, so measuring in the handler would flush layout per event. - // The container measurement `computeMaxWidth` does need costs one flush, but - // it happens inside the coalesced frame, not per event. - // The clamp also has to land without animating — the transition on the panel - // would otherwise make the embedded browser view chase a moving rect for - // 200ms after the drag stops. - useEffect(() => { - let rafId: number | null = null - - const clampWidth = () => { - rafId = null + /** 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 (!el) return - const pinned = el.style.width - const divider = focusedDividerRef.current - const reportsToDivider = divider !== null && document.activeElement === divider - if (!pinned && !reportsToDivider) return + if (!key || !el || cleanupRef.current) return const maxWidth = measureMaxWidth(el) - if (pinned && Number.parseFloat(pinned) > maxWidth) writeWidthInstantly(el, maxWidth) - if (reportsToDivider) syncDividerValue(divider, el, maxWidth) - } - - const handleWindowResize = () => { - if (rafId !== null) return - rafId = requestAnimationFrame(clampWidth) - } - - window.addEventListener('resize', handleWindowResize) - return () => { - window.removeEventListener('resize', handleWindowResize) - if (rafId !== null) cancelAnimationFrame(rafId) - } - }, []) - - /** 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) - }, []) + const width = keyboardPanelWidth(key, el.getBoundingClientRect().width, maxWidth) + e.preventDefault() + e.stopPropagation() + writeWidthInstantly(el, width) + rememberWidth(width) + syncDividerValue(e.currentTarget, el, maxWidth) + }, + [rememberWidth] + ) /** Reports the current width when the divider takes focus, and while it keeps focus. */ const handleResizeFocus = useCallback((e: React.FocusEvent) => { @@ -299,16 +346,10 @@ export function useMothershipResize(desktopScopeId: string) { 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, + mothershipRef: attachPanel, handleResizePointerDown, handleResizeKeyDown, handleResizeFocus, - clearWidth, } } 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 2778516a241..046da128f3f 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 @@ -154,7 +154,8 @@ export function useChatResourcePanel( | 'removeResource' | 'setActiveResourceId' >, - controller: ReturnType + controller: ReturnType, + userId?: string ) { const { desktopScopeId, @@ -182,22 +183,16 @@ export function useChatResourcePanel( effectiveActiveResourceIdRef, onResourceEvent: handleResourceEvent, } = controller - const { - mothershipRef, - handleResizePointerDown, - handleResizeKeyDown, - handleResizeFocus, - clearWidth, - } = useMothershipResize(desktopScopeId) + const { mothershipRef, handleResizePointerDown, handleResizeKeyDown, handleResizeFocus } = + useMothershipResize(desktopScopeId, { userId, collapsed: isResourceCollapsed }) effectiveActiveResourceIdRef.current = activeResourceId const resourceAttentionChatIdRef = useRef(resolvedChatId) const collapseResource = useCallback(() => { resourceCollapseOwnedByUserRef.current = true resourceSelectionOwnedByUserRef.current = true - clearWidth() setResourceCollapsed(true) - }, [clearWidth, setResourceCollapsed]) + }, [setResourceCollapsed]) const clearResourceActivity = useCallback((resourceId: string) => { setResourceActivityIds((current) => { @@ -279,7 +274,6 @@ export function useChatResourcePanel( const previousChatId = resourceAttentionChatIdRef.current resourceAttentionChatIdRef.current = resolvedChatId if (!resolvedChatId) { - clearWidth() setResourceCollapsed(true) } if (!resolvedChatId || (previousChatId && previousChatId !== resolvedChatId)) { @@ -287,7 +281,7 @@ export function useChatResourcePanel( resourceSelectionOwnedByUserRef.current = false setResourceActivityIds(new Set()) } - }, [resolvedChatId, clearWidth, setResourceCollapsed]) + }, [resolvedChatId, setResourceCollapsed]) useEffect(() => { if ( @@ -304,10 +298,9 @@ export function useChatResourcePanel( useEffect(() => { if (resources.length === 0 && !isResourceCollapsedRef.current) { - clearWidth() setResourceCollapsed(true) } - }, [resources, clearWidth, setResourceCollapsed]) + }, [resources, setResourceCollapsed]) useEffect(() => { const resourceIds = new Set(resources.map(getChatResourceSelectionId)) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index 1da14a9bf22..6fd710207d1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -24,6 +24,7 @@ import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { signOutAndRedirect } from '@/lib/auth/sign-out' import { getDesktopUpdates } from '@/lib/desktop' +import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' import { getUserColor } from '@/lib/workspaces/colors' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { @@ -227,7 +228,10 @@ export function SidebarFooter({ href={href} onNavigate={(event) => { event.preventDefault() - useSettingsDirtyStore.getState().requestLeave(onNavigate) + useSettingsDirtyStore.getState().requestLeave(() => { + rememberSettingsReturnUrl(href) + onNavigate() + }) }} > diff --git a/apps/sim/components/settings/settings-guarded-link.tsx b/apps/sim/components/settings/settings-guarded-link.tsx index eb0565c27ee..337e3f57f51 100644 --- a/apps/sim/components/settings/settings-guarded-link.tsx +++ b/apps/sim/components/settings/settings-guarded-link.tsx @@ -3,6 +3,7 @@ import type { ComponentProps } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' +import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' interface SettingsGuardedLinkProps @@ -23,7 +24,12 @@ export function SettingsGuardedLink({ href, onNavigate, ...props }: SettingsGuar const { isDirty, navigationBlocked, requestLeave } = useSettingsDirtyStore.getState() if (isDirty || navigationBlocked) { event.preventDefault() - requestLeave(() => router.push(href)) + requestLeave(() => { + rememberSettingsReturnUrl(href) + router.push(href) + }) + } else { + rememberSettingsReturnUrl(href) } onNavigate?.() }} diff --git a/apps/sim/components/settings/settings-sidebar.tsx b/apps/sim/components/settings/settings-sidebar.tsx index e26d05a36a7..d8c389c83e8 100644 --- a/apps/sim/components/settings/settings-sidebar.tsx +++ b/apps/sim/components/settings/settings-sidebar.tsx @@ -25,6 +25,7 @@ import { import { SettingsIntentLink } from '@/components/settings/settings-intent-link' import { usePendingSettingsSelection } from '@/components/settings/use-pending-settings-selection' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { popSettingsReturnUrl } from '@/lib/navigation/settings-return' import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-section' import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-tooltip' import { @@ -134,7 +135,7 @@ export function SettingsSidebar
({ fullWidth leftIcon={ChevronLeft} className={SIDEBAR_RAIL_CHIP_CLASS} - onClick={() => requestLeave(() => router.push(backHref))} + onClick={() => requestLeave(() => router.push(popSettingsReturnUrl(backHref)))} > Back diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 8509d0d33be..ef40c531c2d 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -27,6 +27,7 @@ import { } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' import { organizationRoutes } from '@/lib/navigation/paths' +import { SETTINGS_RETURN_URL_KEY } from '@/lib/navigation/settings-return' import { stripMicrosoftDataverseEnvironmentFromOAuthCallback } from '@/lib/oauth/microsoft-dataverse' import { searchSetupAccessParam } from '@/lib/sim-search/search-params' import { organizationSearchSetupPath } from '@/lib/sim-search/setup-navigation' @@ -36,7 +37,6 @@ import { workspaceCredentialKeys, } from '@/hooks/queries/utils/credential-keys' import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' -import { SETTINGS_RETURN_URL_KEY } from '@/hooks/use-settings-navigation' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const CONTEXT_MAX_AGE_MS = 15 * 60 * 1000 diff --git a/apps/sim/hooks/use-settings-navigation.test.ts b/apps/sim/hooks/use-settings-navigation.test.ts index 1e8aa042086..e3ee76c8ece 100644 --- a/apps/sim/hooks/use-settings-navigation.test.ts +++ b/apps/sim/hooks/use-settings-navigation.test.ts @@ -11,7 +11,7 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' */ vi.mock('@/lib/auth/auth-client', () => authClientMock) -import { resolveSettingsHref, resolveSettingsReturnUrl } from '@/hooks/use-settings-navigation' +import { resolveSettingsHref } from '@/hooks/use-settings-navigation' const HOST_CONTEXT: WorkspaceHostContext = { workspace: { @@ -53,17 +53,3 @@ describe('resolveSettingsHref unified settings navigation', () => { ).toBe('/workspace/workspace-b/upgrade') }) }) - -describe('resolveSettingsReturnUrl', () => { - const fallback = '/workspace/workspace-b' - - it('discards a stored url captured in a workspace the user has since left', () => { - expect( - resolveSettingsReturnUrl({ - storedUrl: '/workspace/workspace-a/w/workflow-a', - workspaceId: 'workspace-b', - fallback, - }) - ).toBe(fallback) - }) -}) diff --git a/apps/sim/hooks/use-settings-navigation.ts b/apps/sim/hooks/use-settings-navigation.ts index bed928bc466..534309ab677 100644 --- a/apps/sim/hooks/use-settings-navigation.ts +++ b/apps/sim/hooks/use-settings-navigation.ts @@ -6,11 +6,10 @@ import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { popSettingsReturnUrl, rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' -export const SETTINGS_RETURN_URL_KEY = 'settings-return-url' - interface SettingsNavigationOptions { section?: SettingsSection mcpServerId?: string @@ -58,31 +57,6 @@ export function resolveSettingsHref({ return query ? `${pathname}?${query}` : pathname } -interface ResolveSettingsReturnUrlParams { - storedUrl: string | null - workspaceId?: string - fallback: string -} - -/** - * Resolves the stored settings return url, discarding it when it points at a - * different workspace than the one currently open. Switching workspaces from - * settings keeps the user on the new workspace, so a return url captured in the - * old one would silently navigate them back out of it. - */ -export function resolveSettingsReturnUrl({ - storedUrl, - workspaceId, - fallback, -}: ResolveSettingsReturnUrlParams): string { - if (!storedUrl) return fallback - const [, root, storedWorkspaceId] = storedUrl.split('/') - if (root === 'workspace' && storedWorkspaceId && storedWorkspaceId !== workspaceId) { - return fallback - } - return storedUrl -} - export function useSettingsNavigation(): UseSettingsNavigationReturn { const router = useRouter() const params = useParams<{ workspaceId?: string }>() @@ -103,28 +77,13 @@ export function useSettingsNavigation(): UseSettingsNavigationReturn { [hostContext, session?.user?.id, workspaceId] ) - const popSettingsReturnUrl = useCallback( - (fallback: string): string => { - try { - const storedUrl = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) - sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) - return resolveSettingsReturnUrl({ storedUrl, workspaceId, fallback }) - } catch { - return fallback - } - }, - [workspaceId] - ) - const navigateToSettings = useCallback( (options?: SettingsNavigationOptions) => { const currentPath = window.location.pathname if (currentPath.startsWith(settingsPrefix)) { router.replace(getSettingsHref(options), { scroll: false }) } else { - try { - sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, currentPath) - } catch {} + rememberSettingsReturnUrl(getSettingsHref(options)) router.push(getSettingsHref(options)) } }, diff --git a/apps/sim/lib/browser-agent/transport.ts b/apps/sim/lib/browser-agent/transport.ts index 693a5440f70..bf368ce5942 100644 --- a/apps/sim/lib/browser-agent/transport.ts +++ b/apps/sim/lib/browser-agent/transport.ts @@ -659,6 +659,7 @@ export function setBrowserPanelOccluded( * with its left edge shifted by the divider's travel. Call at drag start with * the divider position (the panel's left edge in viewport CSS pixels); the * returned predictor reports a rect per pointer move, before layout runs. + * Pass the current scope with each prediction if a pending chat adopts its durable ID mid-drag. * Measured reports remain authoritative and correct any drift. * * Both `startDividerX` and every `dividerX` must be the panel's REAL viewport @@ -673,10 +674,10 @@ export function setBrowserPanelOccluded( export function beginBrowserPanelDividerDrag( startDividerX: number, scopeId = currentBrowserScopeId() -): ((dividerX: number) => void) | null { +): ((dividerX: number, reportScopeId?: string) => void) | null { const base = latestPanelBoundsByScope.get(scopeId) if (!bridge() || !base) return null - return (dividerX: number) => { + return (dividerX: number, reportScopeId = scopeId) => { const dx = Math.round(dividerX - startDividerX) const width = base.width - dx if (width <= 0) return @@ -686,7 +687,7 @@ export function beginBrowserPanelDividerDrag( reportBrowserPanelBounds( { x: base.x + dx, y: base.y, width, height: base.height }, { viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, widthRatio: 0 }, - scopeId + reportScopeId ) } } diff --git a/apps/sim/lib/navigation/settings-return.test.ts b/apps/sim/lib/navigation/settings-return.test.ts new file mode 100644 index 00000000000..420da2a2103 --- /dev/null +++ b/apps/sim/lib/navigation/settings-return.test.ts @@ -0,0 +1,35 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from 'vitest' +import { popSettingsReturnUrl, rememberSettingsReturnUrl } from '@/lib/navigation/settings-return' + +describe('settings round trips', () => { + beforeEach(() => sessionStorage.clear()) + + it.each(['/workspace/workspace-a', '/o/organization-a'])( + 'restores the complete chat URL in %s without replacing it on section changes', + (scope) => { + const original = `${scope}/chat/chat-a?resource=file-a&view=view-a#selection` + window.history.replaceState(null, '', original) + rememberSettingsReturnUrl(`${scope}/settings/general`) + window.history.replaceState(null, '', `${scope}/settings/general`) + rememberSettingsReturnUrl(`${scope}/settings/profile`) + window.history.replaceState(null, '', `${scope}/settings/profile`) + expect(popSettingsReturnUrl(`${scope}/home`)).toBe(original) + expect(popSettingsReturnUrl(`${scope}/home`)).toBe(`${scope}/home`) + } + ) + + it('does not carry a return destination into a different organization', () => { + window.history.replaceState(null, '', '/o/organization-a/chat/chat-a?resource=file-a') + rememberSettingsReturnUrl('/o/organization-a/settings/general') + window.history.replaceState(null, '', '/o/organization-b/settings/general') + expect(popSettingsReturnUrl('/o/organization-b/home')).toBe('/o/organization-b/home') + }) + + it('does not store an origin from outside the destination settings scope', () => { + window.history.replaceState(null, '', '/workspace/workspace-a/chat/chat-a') + rememberSettingsReturnUrl('/o/organization-a/settings/general') + window.history.replaceState(null, '', '/o/organization-a/settings/general') + expect(popSettingsReturnUrl('/o/organization-a/home')).toBe('/o/organization-a/home') + }) +}) diff --git a/apps/sim/lib/navigation/settings-return.ts b/apps/sim/lib/navigation/settings-return.ts new file mode 100644 index 00000000000..b2466ddc352 --- /dev/null +++ b/apps/sim/lib/navigation/settings-return.ts @@ -0,0 +1,27 @@ +export const SETTINGS_RETURN_URL_KEY = 'settings-return-url' + +function settingsScope(pathname: string): string | undefined { + return pathname.match(/^(\/(?:workspace|o)\/[^/?#]+)\/settings(?:\/|$)/)?.[1] +} + +/** Captures a complete return URL only when entering settings from the same owning surface. */ +export function rememberSettingsReturnUrl(settingsHref: string): void { + const scope = settingsScope(settingsHref) + const { pathname, search, hash } = window.location + if (!scope || !pathname.startsWith(`${scope}/`) || settingsScope(pathname)) return + try { + sessionStorage.setItem(SETTINGS_RETURN_URL_KEY, `${pathname}${search}${hash}`) + } catch {} +} + +/** Consumes the saved destination only when it belongs to the current settings owner. */ +export function popSettingsReturnUrl(fallback: string): string { + try { + const stored = sessionStorage.getItem(SETTINGS_RETURN_URL_KEY) + sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) + const scope = settingsScope(window.location.pathname) + return scope && stored?.startsWith(`${scope}/`) && !settingsScope(stored) ? stored : fallback + } catch { + return fallback + } +} diff --git a/apps/sim/scripts/fixtures/chat-panel.tsx b/apps/sim/scripts/fixtures/chat-panel.tsx new file mode 100644 index 00000000000..a6c68711c01 --- /dev/null +++ b/apps/sim/scripts/fixtures/chat-panel.tsx @@ -0,0 +1,120 @@ +import { lazy, StrictMode, Suspense, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { + activateBrowserScope, + migrateBrowserScope, + openUrlInNewBrowserTab, + reportBrowserPanelBounds, +} from '@/lib/browser-agent/transport' +import { + ChatPanelContent, + ChatPanelLayout, +} from '@/app/workspace/[workspaceId]/home/components/chat-panel-layout' +import { useMothershipResize } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-resize' +import { useChatPanelStore } from '@/stores/chat-panel/store' + +const LazyContent = lazy(async () => ({ default: ChatPanelContent })) + +interface ChatFixtureProps { + chatId: string + userId: string +} + +function ChatFixture({ chatId, userId }: ChatFixtureProps) { + const browserHost = useRef(null) + const [collapsed, setCollapsed] = useState(false) + const panel = useMothershipResize(chatId, { userId, collapsed }) + const startBrowser = async () => { + await activateBrowserScope(chatId) + await openUrlInNewBrowserTab( + `${location.origin.replace('127.0.0.1', 'localhost')}/page`, + chatId + ) + const rect = browserHost.current?.getBoundingClientRect() + if (rect) + reportBrowserPanelBounds( + { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + null, + chatId + ) + } + return ( + setCollapsed(!collapsed)} + onResize={panel.handleResizePointerDown} + onResizeKeyDown={panel.handleResizeKeyDown} + onResizeFocus={panel.handleResizeFocus} + panel={ + + +
+ Resource +
+
+
+ } + > +
+ Chat + +
+
+ ) +} + +interface ChatPanelFixtureProps { + [key: string]: never +} + +function ChatPanelFixture(_props: ChatPanelFixtureProps) { + const [chatId, setChatId] = useState('workspace-chat-a') + const [userId, setUserId] = useState('user-a') + const [settings, setSettings] = useState(false) + const [narrow, setNarrow] = useState(false) + return ( + <> + +
+ {!settings && } +
+ + ) +} + +const root = document.getElementById('root') +if (!root) throw new Error('Missing fixture root') +createRoot(root).render( + + + +) diff --git a/apps/sim/stores/chat-panel/store.test.ts b/apps/sim/stores/chat-panel/store.test.ts new file mode 100644 index 00000000000..9f744d77943 --- /dev/null +++ b/apps/sim/stores/chat-panel/store.test.ts @@ -0,0 +1,40 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useChatPanelStore } from '@/stores/chat-panel/store' + +describe('chat identity adoption during a resize', () => { + beforeEach(() => { + useChatPanelStore.getState().reset() + }) + + it.each([undefined, 600])( + 'lands a late gesture on the assigned chat (previous width: %s)', + (initialWidth) => { + const store = useChatPanelStore.getState() + if (initialWidth !== undefined) store.setWidth('user-a', 'pending:chat', initialWidth) + store.migrate('pending:chat', 'chat-a') + store.setWidth('user-a', 'pending:chat', 700) + expect(useChatPanelStore.getState().widths).toEqual({ 'user-a:chat-a': 700 }) + } + ) +}) + +describe('cold chat panel preferences', () => { + it.each(['migrate', 'resize'] as const)( + 'preserves saved chats when %s precedes panel attachment', + async (action) => { + vi.resetModules() + localStorage.setItem( + 'chat-panel-widths', + JSON.stringify({ state: { widths: { 'user-a:existing-chat': 720 } }, version: 0 }) + ) + const { useChatPanelStore: coldStore } = await import('@/stores/chat-panel/store') + if (action === 'migrate') coldStore.getState().migrate('pending:chat', 'chat-a') + else coldStore.getState().setWidth('user-a', 'chat-a', 700) + expect(coldStore.getState().widths['user-a:existing-chat']).toBe(720) + expect( + JSON.parse(localStorage.getItem('chat-panel-widths')!).state.widths['user-a:existing-chat'] + ).toBe(720) + } + ) +}) diff --git a/apps/sim/stores/chat-panel/store.ts b/apps/sim/stores/chat-panel/store.ts new file mode 100644 index 00000000000..d5c10a4f245 --- /dev/null +++ b/apps/sim/stores/chat-panel/store.ts @@ -0,0 +1,116 @@ +import { createLogger } from '@sim/logger' +import { toRecord } from '@sim/utils/object' +import { LRUCache } from 'lru-cache' +import { create } from 'zustand' +import { createJSONStorage, devtools, persist } from 'zustand/middleware' +import { MOTHERSHIP_WIDTH } from '@/stores/constants' +import { registerUserDataReset } from '@/stores/user-data-reset-registry' + +const STORAGE_KEY = 'chat-panel-widths' +const MAX_SAVED_CHATS = 200 +const logger = createLogger('ChatPanelStore') +/** A drag can finish after the server assigns its pending chat a durable ID. */ +const adoptedChatKeys = new LRUCache({ max: MAX_SAVED_CHATS }) + +interface ChatPanelState { + widths: Record + resolveChatId: (chatId: string) => string + setWidth: (userId: string, chatId: string, width: number) => void + migrate: (fromChatId: string, toChatId: string) => void + reset: () => void +} + +function validWidth(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= MOTHERSHIP_WIDTH.MIN +} + +/** Bounds device-local history by most recently adjusted chat, including restored storage. */ +function boundedWidths(value: unknown): Record { + const widths: Record = {} + for (const [key, width] of Object.entries(toRecord(value)).slice(-MAX_SAVED_CHATS)) { + if (key && validWidth(width)) widths[key] = width + } + return widths +} + +/** Storage failure must not prevent resizing or retaining preferences for the current session. */ +const storage = createJSONStorage>(() => ({ + getItem: (key) => { + try { + return window.localStorage.getItem(key) + } catch { + return null + } + }, + setItem: (key, value) => { + try { + window.localStorage.setItem(key, value) + } catch { + logger.warn('Unable to save chat panel preferences') + } + }, + removeItem: (key) => { + try { + window.localStorage.removeItem(key) + } catch { + logger.warn('Unable to clear chat panel preferences') + } + }, +})) + +export const useChatPanelStore = create()( + devtools( + persist( + (set, get) => ({ + widths: {}, + resolveChatId: (chatId) => adoptedChatKeys.get(chatId) ?? chatId, + setWidth: (userId, chatId, width) => { + if (!validWidth(width)) return + if (!useChatPanelStore.persist.hasHydrated()) void useChatPanelStore.persist.rehydrate() + const key = `${userId}:${get().resolveChatId(chatId)}` + if (get().widths[key] === width) return + set((state) => { + const { [key]: _previous, ...rest } = state.widths + return { widths: boundedWidths({ ...rest, [key]: width }) } + }) + }, + migrate: (fromChatId, toChatId) => { + if (fromChatId === toChatId) return + if (!useChatPanelStore.persist.hasHydrated()) void useChatPanelStore.persist.rehydrate() + adoptedChatKeys.set(fromChatId, toChatId) + const previous = get().widths + const widths = { ...previous } + let changed = false + for (const [key, width] of Object.entries(previous)) { + if (!key.endsWith(`:${fromChatId}`)) continue + const destination = `${key.slice(0, -fromChatId.length)}${toChatId}` + widths[destination] ??= width + delete widths[key] + changed = true + } + if (changed) set({ widths }) + }, + reset: () => { + adoptedChatKeys.clear() + set({ widths: {} }) + }, + }), + { + name: STORAGE_KEY, + storage, + skipHydration: true, + partialize: ({ widths }) => ({ widths }), + merge: (persisted, current) => ({ + ...current, + widths: boundedWidths(toRecord(persisted).widths), + }), + } + ), + { name: 'chat-panel' } + ) +) + +registerUserDataReset(STORAGE_KEY, () => { + useChatPanelStore.getState().reset() + void useChatPanelStore.persist.clearStorage() +})