diff --git a/e2e/admin-rbac.spec.ts b/e2e/admin-rbac.spec.ts index 421180a68c..fe44bef482 100644 --- a/e2e/admin-rbac.spec.ts +++ b/e2e/admin-rbac.spec.ts @@ -73,7 +73,7 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn await page.goto(`/admin/users?q=rbac-${runId}`) await expect( - page.getByRole('button', { name: memberUser.username }), + page.getByRole('link', { name: memberUser.username }), ).toBeVisible() await expect(page.getByText(memberUser.email)).toBeVisible() await expect(page.getByText('memberPrivateSecret')).toHaveCount(0) @@ -92,13 +92,13 @@ test('admin RBAC controls access, role assignment, and privacy boundaries', asyn expect(JSON.stringify(memberRecord)).not.toContain('memberPrivateSecret') expect(JSON.stringify(memberRecord)).not.toContain('super-secret-value') - await page.getByRole('button', { name: memberUser.username }).click() + await page.getByRole('link', { name: memberUser.username }).click() await expect(page.getByText('Account metadata only')).toBeVisible() await expect(page.getByText('memberPrivateSecret')).toHaveCount(0) await expect(page.getByText('super-secret-value')).toHaveCount(0) await page.goto(`/admin/users?q=rbac-${runId}`) - await page.getByRole('button', { name: memberUser.username }).click() + await page.getByRole('link', { name: memberUser.username }).click() const roleSelect = page.getByLabel('Role', { exact: true }) await roleSelect.selectOption('admin') await page.getByRole('button', { name: 'Assign', exact: true }).click() diff --git a/packages/worker/client/client-router.tsx b/packages/worker/client/client-router.tsx index 903ef214be..1a7035adbe 100644 --- a/packages/worker/client/client-router.tsx +++ b/packages/worker/client/client-router.tsx @@ -239,7 +239,17 @@ export function matchRoute( ) } -function shouldHandleClick(event: MouseEvent, anchor: HTMLAnchorElement) { +/** + * True when the router will intercept this anchor click and run an SPA + * navigation (plain left-click on a same-origin, non-download, self-target + * link). Components that reset local state before an in-page navigation use + * this so modified clicks (open in new tab, download) leave the current page + * untouched. + */ +export function shouldRouterHandleClick( + event: MouseEvent, + anchor: HTMLAnchorElement, +) { if (event.defaultPrevented) return false if (event.button !== 0) return false if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) @@ -259,7 +269,7 @@ function handleDocumentClick(event: MouseEvent) { const target = event.target as Element | null const anchor = target?.closest('a') as HTMLAnchorElement | null if (!anchor || typeof window === 'undefined') return - if (!shouldHandleClick(event, anchor)) return + if (!shouldRouterHandleClick(event, anchor)) return event.preventDefault() const destination = new URL(anchor.href, window.location.href) diff --git a/packages/worker/client/routes/account-activity.tsx b/packages/worker/client/routes/account-activity.tsx index 37ae5edb77..558abd4a84 100644 --- a/packages/worker/client/routes/account-activity.tsx +++ b/packages/worker/client/routes/account-activity.tsx @@ -20,7 +20,7 @@ import { accountManagementNarrowMq, AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementShell, AccountManagementSidebar, @@ -683,17 +683,13 @@ export function AccountActivityRoute(handle: Handle) { {runs.map((item) => (
  • - { - setMessage(null) - navigate( - activityRoute.buildDetailHref( - item.id, - filterSearch, - ), - ) - }} + onNavigate={() => setMessage(null)} > ) : null} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-email.tsx b/packages/worker/client/routes/account-email.tsx index 4bc786ce4b..ec69e89e7b 100644 --- a/packages/worker/client/routes/account-email.tsx +++ b/packages/worker/client/routes/account-email.tsx @@ -3,7 +3,7 @@ import { formatNullableTimestamp } from '#client/format-timestamp.ts' import { type Handle, css } from 'remix/ui' import { Tab, TabList, TabPanel, Tabs } from 'remix/ui/tabs' import { on } from '#client/event-mixin.ts' -import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { readCurrentRouterHref } from '#client/client-router.tsx' import { createListDetailRoute } from '#client/list-detail-route.ts' import { replaceLocation } from '#client/replace-location.ts' import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' @@ -16,7 +16,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -494,19 +494,15 @@ export function AccountEmailRoute(handle: Handle) { {data.messages.map((emailMessage) => (
  • - { - navigate( - emailRoute.buildDetailHref( - emailMessage.id, - getCurrentSearch(), - ), - ) - }} >
    ) : null} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-integrations.tsx b/packages/worker/client/routes/account-integrations.tsx index 3613b1aca5..bd7af05463 100644 --- a/packages/worker/client/routes/account-integrations.tsx +++ b/packages/worker/client/routes/account-integrations.tsx @@ -6,7 +6,7 @@ import { } from '#app/loader-data.ts' import { routes } from '#app/routes.ts' import { type Handle, css } from 'remix/ui' -import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { readCurrentRouterHref } from '#client/client-router.tsx' import { CopyTextButton } from '#client/copy-text-button.tsx' import { on } from '#client/event-mixin.ts' import { createListDetailRoute } from '#client/list-detail-route.ts' @@ -27,7 +27,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -513,44 +513,39 @@ export function AccountIntegrationsRoute(handle: Handle) { key={group.appSlug} mix={css({ display: 'grid', gap: spacing.xs })} > - + {group.connections.map((integration) => (
  • - { - navigate( - integrationsRoute.buildDetailHref( - integration.name, - getCurrentSearch(), - ), - ) - }} > )} - +
  • ))}
    @@ -646,42 +637,37 @@ export function AccountIntegrationsRoute(handle: Handle) { key={app.slug} mix={css({ display: 'grid', gap: spacing.xs })} > - + ))} @@ -814,6 +800,7 @@ export function AccountIntegrationsRoute(handle: Handle) { connection.name, getCurrentSearch(), )} + data-prevent-scroll-reset mix={css(primaryLinkCss)} > {connection.accountLabel?.trim() || connection.name} diff --git a/packages/worker/client/routes/account-jobs.tsx b/packages/worker/client/routes/account-jobs.tsx index 99ec068069..ba0455e3ca 100644 --- a/packages/worker/client/routes/account-jobs.tsx +++ b/packages/worker/client/routes/account-jobs.tsx @@ -27,7 +27,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -709,20 +709,17 @@ export function AccountJobsRoute(handle: Handle) { {filteredJobs.map((item) => (
  • - { - if (isMutating) return + onNavigate={() => { deleteJobCheck.reset() editing = false setMessage(null) - navigate( - jobsRoute.buildDetailHref( - item.id, - getCurrentSearch(), - ), - ) }} > {statusLabel(item)} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-management-components.tsx b/packages/worker/client/routes/account-management-components.tsx index ca270d7053..615db7db16 100644 --- a/packages/worker/client/routes/account-management-components.tsx +++ b/packages/worker/client/routes/account-management-components.tsx @@ -1,6 +1,7 @@ import { css, type Handle } from 'remix/ui' import { routes } from '#app/routes.ts' import { on } from '#client/event-mixin.ts' +import { shouldRouterHandleClick } from '#client/client-router.tsx' import { colors, radius, @@ -677,28 +678,52 @@ export function AccountManagementSearchField( ) } -type AccountManagementListItemButtonProps = { +type AccountManagementListItemLinkProps = { + href: string active: boolean disabled?: boolean - onClick: () => void + /** + * Pre-navigation state resets (clear messages, collapse delete + * confirmations). Runs only for clicks the router intercepts as an SPA + * navigation, so open-in-new-tab clicks leave the current pane untouched. + */ + onNavigate?: () => void children: AccountManagementSlot } -export function AccountManagementListItemButton( - handle: Handle, +/** + * List/detail sidebar entry rendered as a real link: middle/cmd-click opens + * the record in a new tab, hover warms the destination loader, and + * `data-prevent-scroll-reset` keeps the page's scroll position when the + * detail pane swaps in. While `disabled`, the `href` is dropped so the router + * ignores clicks mid-mutation. + */ +export function AccountManagementListItemLink( + handle: Handle, ) { return () => ( - + ) } diff --git a/packages/worker/client/routes/account-mcp-servers.tsx b/packages/worker/client/routes/account-mcp-servers.tsx index 9bba4fd498..a439db9ee6 100644 --- a/packages/worker/client/routes/account-mcp-servers.tsx +++ b/packages/worker/client/routes/account-mcp-servers.tsx @@ -20,7 +20,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -461,19 +461,16 @@ export function AccountMcpServersRoute(handle: Handle) { {filteredServers.map((item) => (
  • - { - if (isMutating) return + onNavigate={() => { deleteServerCheck.reset() setMessage(null) - navigate( - mcpServersRoute.buildDetailHref( - item.id, - getCurrentSearch(), - ), - ) }} > {item.name} @@ -488,7 +485,7 @@ export function AccountMcpServersRoute(handle: Handle) { ? ` · ${item.toolCount} tool${item.toolCount === 1 ? '' : 's'}` : ''} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-memories.tsx b/packages/worker/client/routes/account-memories.tsx index 2906028945..9a53fc934a 100644 --- a/packages/worker/client/routes/account-memories.tsx +++ b/packages/worker/client/routes/account-memories.tsx @@ -24,7 +24,7 @@ import { matchesSearchQuery } from '#client/search-filter.ts' import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -401,19 +401,16 @@ export function AccountMemoriesRoute(handle: Handle) { {filteredMemories.map((item) => (
  • - { - if (isMutating) return + onNavigate={() => { deleteMode = null setMessage(null) - navigate( - memoriesRoute.buildDetailHref( - item.id, - getCurrentSearch(), - ), - ) }} > {item.summary} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-package-invocation-tokens.tsx b/packages/worker/client/routes/account-package-invocation-tokens.tsx index 2c0d7d7bcf..7e10701f3d 100644 --- a/packages/worker/client/routes/account-package-invocation-tokens.tsx +++ b/packages/worker/client/routes/account-package-invocation-tokens.tsx @@ -48,7 +48,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -931,23 +931,19 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) { } } - function selectToken(token: AccountPackageInvocationTokenListItem) { - selectedTokenId = token.id - editorState = token.revokedAt - ? createEmptyEditorState() - : createEditorStateFromToken(token) - lastNewTokenQueryKey = '' + /** + * Local cleanup when a sidebar link starts navigating to another token. + * Editor state is deliberately NOT seeded here: until the navigation + * commits, the URL (and therefore `getCurrentSelectedTokenId`) still + * points at the previous token, and a save during that window would write + * the new token's editor fields onto the old token. `applyPayload` + * initializes the editor from the committed payload instead. + */ + function resetTokenSelectionState() { revokeConfirm = false deleteTokenCheck.reset() - editMode = !token.revokedAt message = null messageTone = 'info' - syncRouterLocation( - packageInvocationTokensRoute.buildDetailHref( - token.id, - getCurrentSearch(), - ), - ) handle.update() } @@ -1072,13 +1068,14 @@ export function AccountPackageInvocationTokensRoute(handle: Handle) { const isSelected = effectiveSelectedTokenId === token.id return (
  • - { - if (isMutating) return - selectToken(token) - }} + onNavigate={resetTokenSelectionState} >
    Exports: {formatScope(token.exportNames)} - +
  • ) })} diff --git a/packages/worker/client/routes/account-packages.tsx b/packages/worker/client/routes/account-packages.tsx index ececcb7991..151f1f06ce 100644 --- a/packages/worker/client/routes/account-packages.tsx +++ b/packages/worker/client/routes/account-packages.tsx @@ -1,7 +1,7 @@ import { formatTimestamp } from '#client/format-timestamp.ts' import { type Handle, css } from 'remix/ui' import { on } from '#client/event-mixin.ts' -import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { readCurrentRouterHref } from '#client/client-router.tsx' import { createListDetailRoute } from '#client/list-detail-route.ts' import { replaceLocation } from '#client/replace-location.ts' import { @@ -23,7 +23,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -432,16 +432,12 @@ export function AccountPackagesRoute(handle: Handle) { {packages.map((pkg) => (
  • - { - navigate( - packagesRoute.buildDetailHref( - pkg.id, - getCurrentSearch(), - ), - ) - }} >
    ) : null} - +
  • ))} {hasMore ? ( diff --git a/packages/worker/client/routes/account-remote-connectors.tsx b/packages/worker/client/routes/account-remote-connectors.tsx index 55da3733b2..d4ab59e6a4 100644 --- a/packages/worker/client/routes/account-remote-connectors.tsx +++ b/packages/worker/client/routes/account-remote-connectors.tsx @@ -21,7 +21,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -647,15 +647,11 @@ export function AccountRemoteConnectorsRoute(handle: Handle) { } function selectConnector(connector: RemoteConnectorListItem) { - if (saveState !== 'idle') return editorState = createEditorStateFromConnector(connector) syncedSelectionKey = `id:${connector.id}` deleteConnectorCheck.reset() showSharedSecret = false message = null - syncRouterLocation( - remoteConnectorsRoute.buildDetailHref(connector.id, getCurrentSearch()), - ) handle.update() } @@ -825,10 +821,14 @@ export function AccountRemoteConnectorsRoute(handle: Handle) { {filteredConnectors.map((connector) => (
  • - selectConnector(connector)} + onNavigate={() => selectConnector(connector)} > {connectorLabel(connector)} - +
  • ))}
    diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index 3f66cac790..8eb1d40a03 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -61,7 +61,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -1366,15 +1366,10 @@ export function AccountSecretsRoute(handle: Handle) { const isActive = activeSecretId === secret.id return (
  • - { - if (isMutating) return - navigate( - buildSecretHref(secret, getCurrentSearch()), - ) - }} >
    ) : null} - +
  • ) })} diff --git a/packages/worker/client/routes/account-values.tsx b/packages/worker/client/routes/account-values.tsx index 598f541ba8..6c99061f7d 100644 --- a/packages/worker/client/routes/account-values.tsx +++ b/packages/worker/client/routes/account-values.tsx @@ -20,7 +20,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementSearchField, AccountManagementShell, @@ -395,13 +395,9 @@ export function AccountValuesRoute(handle: Handle) { } } - function selectValue(entry: AccountValueListItem) { - if (saveState !== 'idle') return + function resetSelectionState() { deleteValueCheck.reset() setMessage(null) - syncRouterLocation( - valuesRoute.buildDetailHref(entry.id, getCurrentSearch()), - ) handle.update() } @@ -545,10 +541,14 @@ export function AccountValuesRoute(handle: Handle) { {filteredValues.map((entry) => (
  • - selectValue(entry)} + onNavigate={resetSelectionState} > {entry.name} {entry.valuePreview || entry.description || '—'} - +
  • ))}
    diff --git a/packages/worker/client/routes/admin-platform-feedback.tsx b/packages/worker/client/routes/admin-platform-feedback.tsx index 284dc2e21c..a3d22ff490 100644 --- a/packages/worker/client/routes/admin-platform-feedback.tsx +++ b/packages/worker/client/routes/admin-platform-feedback.tsx @@ -2,7 +2,7 @@ import { formatNullableTimestamp, formatTimestamp, } from '#client/format-timestamp.ts' -import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { readCurrentRouterHref } from '#client/client-router.tsx' import { on } from '#client/event-mixin.ts' import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' import { consumeStaleNavigationData } from '#client/navigation-data.ts' @@ -30,7 +30,7 @@ import { type Handle, css } from 'remix/ui' import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementShell, AccountManagementSidebar, @@ -386,13 +386,9 @@ export function AdminPlatformFeedbackRoute(handle: Handle) { const isActive = selectedFeedbackId === feedback.id return (
  • - { - navigate( - buildFeedbackHref(currentHref, feedback.id), - ) - }} > ) : null} - +
  • ) })} diff --git a/packages/worker/client/routes/admin-users.tsx b/packages/worker/client/routes/admin-users.tsx index c8fac271e1..4493168bb6 100644 --- a/packages/worker/client/routes/admin-users.tsx +++ b/packages/worker/client/routes/admin-users.tsx @@ -1,7 +1,7 @@ import { formatTimestamp } from '#client/format-timestamp.ts' import { type Handle, css } from 'remix/ui' import { on } from '#client/event-mixin.ts' -import { navigate, readCurrentRouterHref } from '#client/client-router.tsx' +import { readCurrentRouterHref } from '#client/client-router.tsx' import { replaceLocation } from '#client/replace-location.ts' import { createInfiniteList, @@ -24,7 +24,7 @@ import { import { AccountManagementLayout, AccountManagementList, - AccountManagementListItemButton, + AccountManagementListItemLink, AccountManagementMessage, AccountManagementPanel, AccountManagementSearchField, @@ -772,13 +772,10 @@ export function AdminUsersRoute(handle: Handle) { {users.map((user) => (
  • - { - if (isMutating) return - navigate(buildUserDetailHref(user.stableUserId)) - }} > {user.username} @@ -803,7 +800,7 @@ export function AdminUsersRoute(handle: Handle) { ? user.roles.join(', ') : 'No roles'} - +
  • ))} {hasMore ? (