fix(desktop): Rename links go by account id and open once, a started add refreshes the list

The chat notice's Rename link carries the account id; a link whose account is
gone opens nothing and says so. A docked Capabilities page takes each new
link's scope, and of two mounted pages only one acts on a link. A started add
or reconnect refreshes the account list at once.
This commit is contained in:
Siddharth Balyan
2026-10-11 15:57:48 +05:30
committed by Siddharth Balyan
parent e22e4517ad
commit f38da142bc
14 changed files with 209 additions and 37 deletions

View File

@@ -15,6 +15,7 @@ import { useMcpServers } from '../mcp/use-mcp-servers'
import { AddServerDialog } from './add-dialog'
import { ConnectorsDirectory } from './connectors-directory'
import { $abandonedConnects, $accountOperations, abandonConnect, accountOperationFor } from './data/account-operations'
import { claimRouteLink } from './data/deep-link'
import { accountAddress, joinBundledEntries, joinLocalServers, pickAccount } from './data/join'
import { useConnectConnector, useConnectorSwitch, useDisconnectAccount, useRenameAccount } from './data/mutations'
import { seedLocalServers, startConnectorPersistence, storeLocalServers } from './data/persist'
@@ -117,7 +118,7 @@ export function ConnectorsTab({ gateway, profile }: ConnectorsTabProps) {
const openCard = useMemo(() => cards.find(card => cardKey(card) === openKey) ?? null, [cards, openKey])
useOpenFromRoute(cards, setOpenKey, setPendingRename)
useOpenFromRoute(cards, profile, setOpenKey, setPendingRename)
const write = async (pending: Promise<{ error?: unknown; ok: boolean }>) => {
const outcome = await pending
@@ -378,10 +379,11 @@ function HostedNotice({ hasGuest, phase }: { hasGuest: boolean; phase: HostedPha
function useOpenFromRoute(
cards: readonly ConnectorCardModel[],
scope: ProfileScope,
open: (key: string) => void,
rename: (target: PendingRename) => void
): void {
const { hash, pathname, search } = useLocation()
const { hash, key: locationKey, pathname, search } = useLocation()
const navigate = useNavigate()
useEffect(() => {
@@ -397,7 +399,9 @@ function useOpenFromRoute(
? cards.find(card => card.residency === 'local' && card.slug === server)
: cards.find(card => card.slug === slug)
if (!target) {
// A tab for another profile leaves the link to the page once it takes the link's scope, and of two
// mounted pages (one docked) only one acts on it.
if (!target || !claimRouteLink(locationKey, params, scope)) {
return
}
@@ -405,10 +409,10 @@ function useOpenFromRoute(
openToolsList(toolsListKey(target))
}
const alias = params.get('rename')
const connectionId = params.get('rename')
if (alias) {
rename({ alias, slug: target.slug })
if (connectionId) {
rename({ connectionId, slug: target.slug })
}
open(cardKey(target))
@@ -421,5 +425,5 @@ function useOpenFromRoute(
const query = params.toString()
navigate({ hash, pathname, search: query ? `?${query}` : '' }, { replace: true })
}, [cards, hash, navigate, open, pathname, rename, search])
}, [cards, hash, locationKey, navigate, open, pathname, rename, scope, search])
}

View File

@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { queryClient } from '@/lib/query-client'
import { setPrimaryGateway } from '@/store/gateway'
import { renameLinkTarget } from '../hosted-dialog'
import type { AccountRow } from '../types'
import { accountRenameRoute, claimRouteLink, resetRouteLinksForTests } from './deep-link'
import { connectorsAccountsQueryKey } from './keys'
import { useConnectConnector } from './mutations'
const row = (connectionId: string, alias: string): AccountRow => ({
active: true,
alias,
connection_id: connectionId,
connector: 'gmail',
created_at: '2026-10-01T00:00:00Z',
label: 'Gmail account',
status: 'active',
updated_at: '2026-10-01T00:00:00Z'
})
afterEach(() => {
resetRouteLinksForTests()
setPrimaryGateway(null)
vi.restoreAllMocks()
})
describe('the chat Rename link goes by account id', () => {
it('carries the id, not the name the account had then', () => {
const params = new URLSearchParams(
accountRenameRoute('gmail', 'ca_work', { connectionId: null, profile: 'a' }).split('?')[1]
)
expect(params.get('rename')).toBe('ca_work')
})
it('opens the account with that id, never one that took its old name, and reports a missing one', () => {
const pending = { connectionId: 'ca_work', slug: 'gmail' }
const reused = [row('ca_new', 'work'), row('ca_work', 'office')]
expect(renameLinkTarget(pending, 'gmail', reused, true)).toEqual(reused[1])
expect(renameLinkTarget(pending, 'gmail', [row('ca_new', 'work')], true)).toBe('gone')
expect(renameLinkTarget(pending, 'gmail', [], false)).toBeNull()
expect(renameLinkTarget(pending, 'slack', reused, true)).toBeNull()
})
})
describe('a link is acted on once, by the page on its scope', () => {
it('leaves a link for another profile alone and lets only the first mounted page take it', () => {
const params = new URLSearchParams({ connector: 'gmail', profile: 'b', rename: 'ca_b' })
expect(claimRouteLink('k1', params, 'a')).toBe(false)
expect(claimRouteLink('k1', params, 'b')).toBe(true)
expect(claimRouteLink('k1', params, 'b')).toBe(false)
expect(claimRouteLink('k2', params, 'b')).toBe(true)
})
})
describe('starting an add or a reconnect', () => {
it('refreshes the account list when the start returns, before the operation settles', async () => {
const request = vi.fn(async () => ({ deadline_at: 0, op_id: 'op1', seq: 1, settled: false, targets: [] }))
// SAFETY: the connector RPC layer calls only `request` on the primary socket here.
setPrimaryGateway({ request } as never)
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
const { result } = renderHook(() => useConnectConnector('default'))
await act(async () => {
await result.current.connect('gmail', { alias: 'work' })
})
expect(invalidate).toHaveBeenCalledWith({ queryKey: connectorsAccountsQueryKey('default') })
})
})

View File

@@ -1,3 +1,6 @@
import { type ProfileScope, profileScopeKey } from '@/hermes'
import { activeGatewayConnectionId } from '@/store/gateway'
import { CAPABILITIES_ROUTE } from '../../../routes'
import { $accountOperations } from './account-operations'
@@ -6,14 +9,14 @@ import { wakeAccountOperation } from './rpc'
const connectorRoute = (slug: string): string =>
`${CAPABILITIES_ROUTE}?tab=connectors&connector=${encodeURIComponent(slug)}`
/** The Connectors tab, scoped to the chat's own profile and connection, with one app's dialog and the named
* account's rename editor open. */
/** The Connectors tab, scoped to the chat's own profile and connection, with one app's dialog and that
* account's rename editor open. The account goes by id: its name can change and be reused by another. */
export function accountRenameRoute(
slug: string,
alias: string,
connectionId: string,
owner: { connectionId: null | string; profile: string }
) {
const params = new URLSearchParams({ profile: owner.profile, rename: alias })
const params = new URLSearchParams({ profile: owner.profile, rename: connectionId })
if (owner.connectionId) {
params.set('connection', owner.connectionId)
@@ -43,3 +46,43 @@ export async function resumeAccountConnect(opId: string, navigate: (to: string)
return true
}
/** The scope a link names: the profile, pinned to its connection when that is not the active one. */
export function linkedScope(params: URLSearchParams): null | string | { connectionId: string; profile: string } {
const profile = params.get('profile')?.trim()
if (!profile) {
return null
}
const connectionId = params.get('connection')?.trim()
// The active connection is the ambient route; only another connection needs a pinned scope.
return connectionId && connectionId !== (activeGatewayConnectionId() ?? 'local') ? { connectionId, profile } : profile
}
const claimedLinks = new Set<string>()
/** Whether this mounted Connectors tab handles the link at `locationKey`. A tab for another scope waits for
* the page to switch to the link's scope, and of several mounted pages only the first takes it. */
export function claimRouteLink(locationKey: string, params: URLSearchParams, scope: ProfileScope): boolean {
const linked = linkedScope(params)
if (linked !== null && profileScopeKey(linked) !== profileScopeKey(scope)) {
return false
}
const claim = `${locationKey}?${params.toString()}`
if (claimedLinks.has(claim)) {
return false
}
claimedLinks.add(claim)
return true
}
export function resetRouteLinksForTests(): void {
claimedLinks.clear()
}

View File

@@ -276,8 +276,12 @@ export function useConnectConnector(scope: ProfileScope): ConnectorConnect {
connectionId: options?.connectionId,
reconnect: options?.reconnect ?? false
}
const snapshot = await connectAccountConnectors(scope, [slug], request.reconnect, request)
// The start already lists the new or repaired account as pending; the list must not wait for the settle.
invalidateConnectors(scope, 'accounts')
return { ok: true, operation: startAccountOperation(scope, [slug], snapshot, request) }
} catch (error) {
return { error: asConnectorError(error), ok: false }

View File

@@ -61,6 +61,8 @@ const read = (of: keyof typeof CONNECTOR_LIFETIMES) => ({
export interface HostedConnectorsView {
accounts: readonly ConnectorAccountRow[]
/** The account list has answered at least once, so an account missing from it is gone. */
accountsLoaded: boolean
listSlugs: ReadonlySet<string>
phase: HostedPhase
policy: ConnectorPolicyView
@@ -150,6 +152,7 @@ export function useHostedConnectors(scope: ProfileScope): HostedConnectorsView {
return {
accounts: accounts.data?.accounts ?? [],
accountsLoaded: accounts.data !== undefined,
listSlugs,
phase,
policy: policyView,

View File

@@ -2,7 +2,9 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import type { ProfileScope } from '@/hermes'
import { translateNow } from '@/i18n'
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { notify } from '@/store/notifications'
import type { McpServersController } from '../mcp/use-mcp-servers'
@@ -50,10 +52,26 @@ export interface HostedConnectorDialogProps {
}
export interface PendingRename {
alias: string
/** The account goes by id: a stale link must not open another account that took its old name. */
connectionId: string
slug: string
}
/** The account a pending rename link opens in this app's dialog: the row with that id, 'gone' once the list
* has answered without it, or null while there is nothing to do yet. Never a row matched by name. */
export function renameLinkTarget(
pending: null | PendingRename,
slug: string,
accounts: readonly AccountRow[],
loaded: boolean
): 'gone' | AccountRow | null {
if (pending?.slug !== slug) {
return null
}
return accounts.find(row => row.connection_id === pending.connectionId) ?? (loaded ? 'gone' : null)
}
export interface HostedAccountActions {
add: (alias: string) => Promise<WriteOutcome>
/** An account a deep link asked to rename; that app's dialog opens its editor once the account is listed. */
@@ -101,6 +119,7 @@ export function HostedConnectorDialog({
const accountList = useAccountList(
card,
hosted.accountsLoaded,
accountActions,
operation !== null && !operation.settled,
retryableAccount(operation)
@@ -211,6 +230,7 @@ function menuReconnect(
* rename opens that account's editor once this app lists it. */
function useAccountList(
card: ConnectorCardModel,
accountsLoaded: boolean,
actions: HostedAccountActions,
connecting: boolean,
retryable: null | string
@@ -219,15 +239,20 @@ function useAccountList(
const accounts = card.ways.hosted?.accounts ?? []
const { pendingRename, renameOpened } = actions
const renameTarget =
pendingRename?.slug === card.slug ? accounts.find(row => row.alias === pendingRename.alias) : undefined
const linked = renameLinkTarget(pendingRename, card.slug, accounts, accountsLoaded)
const renameTarget = linked === 'gone' ? undefined : (linked ?? undefined)
const renameGone = linked === 'gone'
useEffect(() => {
if (renameTarget) {
setEdit({ connectionId: renameTarget.connection_id, kind: 'rename' })
renameOpened()
} else if (renameGone) {
// Removed or replaced by a reconnect since the link was made: open nothing and say so.
notify({ kind: 'info', message: translateNow('connectorsPage.accounts.renameGone') })
renameOpened()
}
}, [renameOpened, renameTarget])
}, [renameGone, renameOpened, renameTarget])
const props: AccountsSectionProps = {
accounts,

View File

@@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -13,6 +13,8 @@ import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { linkedScope } from './connectors/data/deep-link'
interface ScopeOption {
key: string
label: string
@@ -53,13 +55,23 @@ export function useCapabilityScope({
fixedProfile?: string
}): CapabilityScope {
const activeProfile = useStore($activeGatewayProfile)
const { search } = useLocation()
const { key: locationKey, search } = useLocation()
// A link from a chat names the chat's own profile (and connection), so the page opens on that scope.
const [scopeOverride, setScopeOverride] = useState<null | string | { connectionId: string; profile: string }>(() =>
linkedScope(new URLSearchParams(search))
const linked = useMemo(() => linkedScope(new URLSearchParams(search)), [search])
const [scopeOverride, setScopeOverride] = useState<null | string | { connectionId: string; profile: string }>(
() => linked
)
// A page that stays mounted (docked) takes each new link's scope before any tab acts on the link.
useEffect(() => {
if (linked !== null) {
setScopeOverride(linked)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [locationKey])
const scopeProfile: ProfileScope = useMemo(
() =>
fixedProfile
@@ -237,16 +249,3 @@ export function CapabilityScopeSelector({
</div>
)
}
function linkedScope(params: URLSearchParams): null | string | { connectionId: string; profile: string } {
const profile = params.get('profile')?.trim()
if (!profile) {
return null
}
const connectionId = params.get('connection')?.trim()
// The active connection is the ambient route; only another connection needs a pinned scope.
return connectionId && connectionId !== (activeGatewayConnectionId() ?? 'local') ? { connectionId, profile } : profile
}

View File

@@ -401,7 +401,13 @@ function NamedAccountNotices({ owner, targets }: { owner: ConnectionOwner; targe
return (
<div className="grid gap-1 px-3.5" data-slot="connector-named-account">
{named.map(target => (
<NamedAccountNotice alias={target.alias ?? ''} key={target.name} name={target.name} owner={owner} />
<NamedAccountNotice
alias={target.alias ?? ''}
connectionId={target.connectionId}
key={target.name}
name={target.name}
owner={owner}
/>
))}
</div>
)
@@ -409,29 +415,33 @@ function NamedAccountNotices({ owner, targets }: { owner: ConnectionOwner; targe
interface NamedAccountProps {
alias: string
/** The account the line is about; the Rename link goes by it, since the name can change hands. */
connectionId: string
name: string
owner: ConnectionOwner
}
function NamedAccountNotice({ alias, name, owner }: NamedAccountProps) {
function NamedAccountNotice({ alias, connectionId, name, owner }: NamedAccountProps) {
const { t } = useI18n()
const inRouter = useInRouterContext()
return (
<p className="flex flex-wrap items-center gap-x-1.5 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-secondary)">
{t.connectors.namedAccount(connectorTitle(name), alias)}
{inRouter ? <RenameAccountLink alias={alias} name={name} owner={owner} /> : null}
{inRouter && connectionId ? (
<RenameAccountLink alias={alias} connectionId={connectionId} name={name} owner={owner} />
) : null}
</p>
)
}
// useNavigate() throws outside a Router; the parent mounts this only inside one.
function RenameAccountLink({ alias, name, owner }: NamedAccountProps) {
function RenameAccountLink({ connectionId, name, owner }: NamedAccountProps) {
const { t } = useI18n()
const navigate = useNavigate()
return (
<Button onClick={() => navigate(accountRenameRoute(name, alias, owner))} size="inline" variant="textStrong">
<Button onClick={() => navigate(accountRenameRoute(name, connectionId, owner))} size="inline" variant="textStrong">
{t.connectors.renameAccount}
</Button>
)

View File

@@ -12,6 +12,7 @@ export const deConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
add: 'Verbinden',
rename: 'Umbenennen',
renameLabel: (name: string) => `Neuer Name für ${name}`,
renameGone: 'Dieses Konto gibt es nicht mehr. Es wurde entfernt oder durch eine neue Verbindung ersetzt.',
remove: 'Entfernen',
removeTitle: (app: string, name: string) => `Das ${app}-Konto ${name} entfernen?`,
removeBody: 'Hermes handelt nicht mehr über dieses Konto. Ihre anderen Konten bleiben verbunden.',

View File

@@ -11,6 +11,7 @@ export const enConnectorAccounts: Translations['connectorsPage']['accounts'] = {
add: 'Connect',
rename: 'Rename',
renameLabel: (name: string) => `New name for ${name}`,
renameGone: 'That account is no longer there. It was removed or replaced by a reconnect.',
remove: 'Remove',
removeTitle: (app: string, name: string) => `Remove the ${name} ${app} account?`,
removeBody: 'Hermes stops acting as this account. Your other accounts stay connected.',

View File

@@ -11,6 +11,7 @@ export const esConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
add: 'Conectar',
rename: 'Cambiar nombre',
renameLabel: (name: string) => `Nuevo nombre para ${name}`,
renameGone: 'Esa cuenta ya no existe. Se quitó o una reconexión la sustituyó.',
remove: 'Quitar',
removeTitle: (app: string, name: string) => `¿Quitar la cuenta ${name} de ${app}?`,
removeBody: 'Hermes deja de actuar con esta cuenta. Tus otras cuentas siguen conectadas.',

View File

@@ -12,6 +12,7 @@ export const frConnectorAccounts: NonNullable<TranslationOverrides['connectorsPa
add: 'Connecter',
rename: 'Renommer',
renameLabel: (name: string) => `Nouveau nom pour ${name}`,
renameGone: 'Ce compte n’existe plus. Il a été supprimé ou remplacé par une reconnexion.',
remove: 'Retirer',
removeTitle: (app: string, name: string) => `Retirer le compte ${app} ${name} ?`,
removeBody: "Hermes cesse d'agir avec ce compte. Vos autres comptes restent connectés.",

View File

@@ -354,6 +354,7 @@
"connectorsPage.accounts.removeBody": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.removeTitle": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.rename": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.renameGone": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.renameLabel": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.retiredCount": ["ar","ja","ru","zh","zh-hant"],
"connectorsPage.accounts.status.expired": ["ar","ja","ru","zh","zh-hant"],

View File

@@ -44,6 +44,7 @@ export interface ConnectorAccountsTranslations {
add: string
rename: string
renameLabel: (name: string) => string
renameGone: string
remove: string
removeTitle: (app: string, name: string) => string
removeBody: string