From 71ccdfef22179084dce66661b1b75650d90de883 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sun, 4 Oct 2026 07:17:57 +0200 Subject: [PATCH 1/5] fix(clients): context handoffs show each side's account badge A handoff between two accounts of one provider showed the same glyph and model on both sides. Each endpoint's provider icon now carries the same colored initials badge as the sidebar and thread rows, using the shared shouldShowInstanceBadge rule, on web and mobile. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 1 + .../features/threads/thread-handoff-row.tsx | 48 +++++++++++++++---- .../src/components/chat/V2LifecycleRow.tsx | 17 +++++-- 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 58191639331a..221782f4cd26 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1619,6 +1619,7 @@ function renderFeedEntry( environmentId={props.environmentId} projectedItem={entry.activities[0]!.projectedItem} iconColor={iconSubtleColor} + surfaceColor={props.screenColor} /> ); } diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx index 3b1b2894a8fc..7715823f2ecc 100644 --- a/apps/mobile/src/features/threads/thread-handoff-row.tsx +++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { resolveHandoffEndpoints } from "@t3tools/client-runtime/handoff"; -import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + normalizeProviderAccentColor, + resolveProviderInstanceDisplayName, + shouldShowInstanceBadge, +} from "@t3tools/client-runtime/state/provider-instance-display"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, @@ -13,7 +17,7 @@ import { Fragment, useMemo } from "react"; import { Alert, Pressable, View, type ColorValue } from "react-native"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; -import { ProviderIcon } from "../../components/ProviderIcon"; +import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; import { environmentThreadDetails } from "../../state/threads"; import { serverEnvironment } from "../../state/server"; import { ThreadContextDivider } from "./thread-context-divider"; @@ -38,6 +42,7 @@ export function ThreadHandoffRow(props: { environmentId: EnvironmentId; projectedItem: OrchestrationV2ProjectedTurnItem; iconColor: ColorValue; + surfaceColor: string; }) { const { item } = props.projectedItem; const endpointsAtom = useMemo( @@ -48,6 +53,7 @@ export function ThreadHandoffRow(props: { const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); if (item.type !== "handoff" || endpoints === null) return null; const color = item.status === "failed" ? "#e11d48" : props.iconColor; + const endpointProps = { providers: config?.providers ?? [], surfaceColor: props.surfaceColor }; return ( ) : null} - + ))} {endpoints.from.length > 0 ? ( ) : null} - + ); @@ -79,6 +85,7 @@ function HandoffEndpoint(props: { instanceId: ProviderInstanceId; model?: string | undefined; providers: ReadonlyArray; + surfaceColor: string; }) { const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId); const model = provider?.models.find((candidate) => candidate.slug === props.model); @@ -87,12 +94,21 @@ function HandoffEndpoint(props: { model?.name ?? props.model ?? (provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId); + const accentColor = normalizeProviderAccentColor(provider?.accentColor); + // Same account badge as the thread rows: a handoff between two accounts of + // one provider would otherwise show the same glyph on both sides. + const showBadge = + provider !== undefined && + shouldShowInstanceBadge( + { driverKind: provider.driver, accentColor }, + props.providers.map((candidate) => ({ driverKind: candidate.driver })), + ); return ( Alert.alert( @@ -101,11 +117,23 @@ function HandoffEndpoint(props: { ) } > - + {showBadge && provider !== undefined ? ( + + ) : ( + + )} {label} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index cdd469f7acb4..93bec4afcaf9 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -37,7 +37,7 @@ import { } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; -import { getProviderInstanceEntry } from "../../providerInstances"; +import { deriveProviderInstanceEntries, shouldShowInstanceBadge } from "../../providerInstances"; import { formatShortTimestamp } from "../../timestampFormat"; import { getTriggerDisplayModelName } from "./providerIconUtils"; import { ProviderInstanceIcon, providerTextColorClassName } from "./ProviderInstanceIcon"; @@ -560,7 +560,11 @@ function HandoffEndpoint(props: { readonly instanceId: ProviderInstanceId; readonly model?: string | undefined; }) { - const entry = getProviderInstanceEntry(props.providers, props.instanceId); + const entries = deriveProviderInstanceEntries(props.providers); + const entry = entries.find((candidate) => candidate.instanceId === props.instanceId); + // Same account badge as the sidebar: a handoff between two accounts of one + // provider would otherwise show the same glyph on both sides. + const showBadge = entry !== undefined && shouldShowInstanceBadge(entry, entries); const model = props.model?.trim(); const providerModel = model === undefined || model.length === 0 @@ -578,14 +582,19 @@ function HandoffEndpoint(props: { render={ Date: Sun, 4 Oct 2026 07:22:10 +0200 Subject: [PATCH 2/5] fix(web): keep getProviderInstanceEntry for the handoff endpoint The badge rule needs the full entry list, but the endpoint itself still resolves through getProviderInstanceEntry, which knip flagged as unused. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/chat/V2LifecycleRow.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 93bec4afcaf9..03288fb1c7c9 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -37,7 +37,11 @@ import { } from "lucide-react"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; -import { deriveProviderInstanceEntries, shouldShowInstanceBadge } from "../../providerInstances"; +import { + deriveProviderInstanceEntries, + getProviderInstanceEntry, + shouldShowInstanceBadge, +} from "../../providerInstances"; import { formatShortTimestamp } from "../../timestampFormat"; import { getTriggerDisplayModelName } from "./providerIconUtils"; import { ProviderInstanceIcon, providerTextColorClassName } from "./ProviderInstanceIcon"; @@ -560,11 +564,12 @@ function HandoffEndpoint(props: { readonly instanceId: ProviderInstanceId; readonly model?: string | undefined; }) { - const entries = deriveProviderInstanceEntries(props.providers); - const entry = entries.find((candidate) => candidate.instanceId === props.instanceId); + const entry = getProviderInstanceEntry(props.providers, props.instanceId); // Same account badge as the sidebar: a handoff between two accounts of one // provider would otherwise show the same glyph on both sides. - const showBadge = entry !== undefined && shouldShowInstanceBadge(entry, entries); + const showBadge = + entry !== undefined && + shouldShowInstanceBadge(entry, deriveProviderInstanceEntries(props.providers)); const model = props.model?.trim(); const providerModel = model === undefined || model.length === 0 From edffc662a75df3a7b342c482b57c7bc951ae9035 Mon Sep 17 00:00:00 2001 From: Vitalii Yehorov Date: Sun, 4 Oct 2026 07:44:58 +0200 Subject: [PATCH 3/5] fix(clients): handoff badges tell ACP agents apart The badge rule skips different ACP registry agents because their glyphs already differ, but the handoff endpoints read only server snapshots, which carry no agent ID. Both clients now take it from the instance settings, as ChatView does, so two different ACP agents no longer get a redundant account badge. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/thread-handoff-row.tsx | 23 +++++++++++++++---- .../src/components/chat/V2LifecycleRow.tsx | 17 ++++++++++---- apps/web/src/providerInstances.ts | 2 +- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-handoff-row.tsx b/apps/mobile/src/features/threads/thread-handoff-row.tsx index 7715823f2ecc..9a84180fc118 100644 --- a/apps/mobile/src/features/threads/thread-handoff-row.tsx +++ b/apps/mobile/src/features/threads/thread-handoff-row.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/client-runtime/state/provider-instance-display"; import type { EnvironmentId, + ProviderInstanceConfig, OrchestrationV2ProjectedTurnItem, ProviderInstanceId, ServerProvider, @@ -53,7 +54,11 @@ export function ThreadHandoffRow(props: { const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); if (item.type !== "handoff" || endpoints === null) return null; const color = item.status === "failed" ? "#e11d48" : props.iconColor; - const endpointProps = { providers: config?.providers ?? [], surfaceColor: props.surfaceColor }; + const endpointProps = { + providers: config?.providers ?? [], + providerInstances: config?.settings.providerInstances, + surfaceColor: props.surfaceColor, + }; return ( ; + providerInstances: Readonly> | undefined; surfaceColor: string; }) { const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId); @@ -96,12 +102,21 @@ function HandoffEndpoint(props: { (provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId); const accentColor = normalizeProviderAccentColor(provider?.accentColor); // Same account badge as the thread rows: a handoff between two accounts of - // one provider would otherwise show the same glyph on both sides. + // one provider would otherwise show the same glyph on both sides. Settings + // carry each ACP instance's agent, which tells its glyph apart. + const badgeEntry = (candidate: ServerProvider) => { + const config = props.providerInstances?.[candidate.instanceId]?.config; + const agentId = typeof config === "object" && config ? Reflect.get(config, "agentId") : null; + return { + driverKind: candidate.driver, + ...(typeof agentId === "string" ? { acpRegistryAgentId: agentId.trim() } : {}), + }; + }; const showBadge = provider !== undefined && shouldShowInstanceBadge( - { driverKind: provider.driver, accentColor }, - props.providers.map((candidate) => ({ driverKind: candidate.driver })), + { ...badgeEntry(provider), accentColor }, + props.providers.map(badgeEntry), ); return ( ) : null}