From f38da142bc98d26fdcf66258ef83c95bb8a802f0 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan Date: Sun, 11 Oct 2026 15:57:48 +0530 Subject: [PATCH] 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. --- .../connectors/connectors-tab.tsx | 18 +++-- .../connectors/data/deep-link.test.ts | 78 +++++++++++++++++++ .../capabilities/connectors/data/deep-link.ts | 51 +++++++++++- .../capabilities/connectors/data/mutations.ts | 4 + .../capabilities/connectors/data/queries.ts | 3 + .../capabilities/connectors/hosted-dialog.tsx | 33 +++++++- .../src/app/capabilities/scope-selector.tsx | 33 ++++---- .../assistant-ui/connector-tool.tsx | 20 +++-- .../desktop/src/i18n/de_connector_accounts.ts | 1 + .../desktop/src/i18n/en_connector_accounts.ts | 1 + .../desktop/src/i18n/es_connector_accounts.ts | 1 + .../desktop/src/i18n/fr_connector_accounts.ts | 1 + apps/desktop/src/i18n/overlay-gaps.json | 1 + apps/desktop/src/i18n/types_connectors.ts | 1 + 14 files changed, 209 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/src/app/capabilities/connectors/data/deep-link.test.ts diff --git a/apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx b/apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx index c495458a50..6aac6e7fc7 100644 --- a/apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx +++ b/apps/desktop/src/app/capabilities/connectors/connectors-tab.tsx @@ -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]) } diff --git a/apps/desktop/src/app/capabilities/connectors/data/deep-link.test.ts b/apps/desktop/src/app/capabilities/connectors/data/deep-link.test.ts new file mode 100644 index 0000000000..3bf2c38dc9 --- /dev/null +++ b/apps/desktop/src/app/capabilities/connectors/data/deep-link.test.ts @@ -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') }) + }) +}) diff --git a/apps/desktop/src/app/capabilities/connectors/data/deep-link.ts b/apps/desktop/src/app/capabilities/connectors/data/deep-link.ts index f88d01fca7..1b97d05b14 100644 --- a/apps/desktop/src/app/capabilities/connectors/data/deep-link.ts +++ b/apps/desktop/src/app/capabilities/connectors/data/deep-link.ts @@ -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() + +/** 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() +} diff --git a/apps/desktop/src/app/capabilities/connectors/data/mutations.ts b/apps/desktop/src/app/capabilities/connectors/data/mutations.ts index 7ff5a12b71..41167ca111 100644 --- a/apps/desktop/src/app/capabilities/connectors/data/mutations.ts +++ b/apps/desktop/src/app/capabilities/connectors/data/mutations.ts @@ -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 } diff --git a/apps/desktop/src/app/capabilities/connectors/data/queries.ts b/apps/desktop/src/app/capabilities/connectors/data/queries.ts index d366017196..68e6f9a403 100644 --- a/apps/desktop/src/app/capabilities/connectors/data/queries.ts +++ b/apps/desktop/src/app/capabilities/connectors/data/queries.ts @@ -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 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, diff --git a/apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx b/apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx index 0de65c242d..ffe3101192 100644 --- a/apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx +++ b/apps/desktop/src/app/capabilities/connectors/hosted-dialog.tsx @@ -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 /** 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, diff --git a/apps/desktop/src/app/capabilities/scope-selector.tsx b/apps/desktop/src/app/capabilities/scope-selector.tsx index 343d5599ad..6af04d1297 100644 --- a/apps/desktop/src/app/capabilities/scope-selector.tsx +++ b/apps/desktop/src/app/capabilities/scope-selector.tsx @@ -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(() => - linkedScope(new URLSearchParams(search)) + const linked = useMemo(() => linkedScope(new URLSearchParams(search)), [search]) + + const [scopeOverride, setScopeOverride] = useState( + () => 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({ ) } - -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 -} diff --git a/apps/desktop/src/components/assistant-ui/connector-tool.tsx b/apps/desktop/src/components/assistant-ui/connector-tool.tsx index ac5ca344b4..758a3c65fc 100644 --- a/apps/desktop/src/components/assistant-ui/connector-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/connector-tool.tsx @@ -401,7 +401,13 @@ function NamedAccountNotices({ owner, targets }: { owner: ConnectionOwner; targe return (
{named.map(target => ( - + ))}
) @@ -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 (

{t.connectors.namedAccount(connectorTitle(name), alias)} - {inRouter ? : null} + {inRouter && connectionId ? ( + + ) : null}

) } // 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 ( - ) diff --git a/apps/desktop/src/i18n/de_connector_accounts.ts b/apps/desktop/src/i18n/de_connector_accounts.ts index 57e662621c..38b04728ca 100644 --- a/apps/desktop/src/i18n/de_connector_accounts.ts +++ b/apps/desktop/src/i18n/de_connector_accounts.ts @@ -12,6 +12,7 @@ export const deConnectorAccounts: NonNullable `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.', diff --git a/apps/desktop/src/i18n/en_connector_accounts.ts b/apps/desktop/src/i18n/en_connector_accounts.ts index 78efcfb351..c0f05f8713 100644 --- a/apps/desktop/src/i18n/en_connector_accounts.ts +++ b/apps/desktop/src/i18n/en_connector_accounts.ts @@ -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.', diff --git a/apps/desktop/src/i18n/es_connector_accounts.ts b/apps/desktop/src/i18n/es_connector_accounts.ts index 07cd03f6b3..67d3773924 100644 --- a/apps/desktop/src/i18n/es_connector_accounts.ts +++ b/apps/desktop/src/i18n/es_connector_accounts.ts @@ -11,6 +11,7 @@ export const esConnectorAccounts: NonNullable `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.', diff --git a/apps/desktop/src/i18n/fr_connector_accounts.ts b/apps/desktop/src/i18n/fr_connector_accounts.ts index dd553102e0..1b16d4df87 100644 --- a/apps/desktop/src/i18n/fr_connector_accounts.ts +++ b/apps/desktop/src/i18n/fr_connector_accounts.ts @@ -12,6 +12,7 @@ export const frConnectorAccounts: NonNullable `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.", diff --git a/apps/desktop/src/i18n/overlay-gaps.json b/apps/desktop/src/i18n/overlay-gaps.json index 385c0d1c64..24068034ae 100644 --- a/apps/desktop/src/i18n/overlay-gaps.json +++ b/apps/desktop/src/i18n/overlay-gaps.json @@ -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"], diff --git a/apps/desktop/src/i18n/types_connectors.ts b/apps/desktop/src/i18n/types_connectors.ts index 4aa490d611..6cb5886bf2 100644 --- a/apps/desktop/src/i18n/types_connectors.ts +++ b/apps/desktop/src/i18n/types_connectors.ts @@ -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