Skip to content

Commit e13f83b

Browse files
vitalyiegorovclaude
andcommitted
fix(clients): handoffs between accounts show which account is on each side
A context 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, for both settled and pending handoffs. ACP agent IDs come from the instance settings, so two different ACP agents (whose glyphs already differ) get no badge, and a local ACP command counts as one provider, as on web. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 1451e7c commit e13f83b

4 files changed

Lines changed: 90 additions & 22 deletions

File tree

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,7 @@ function renderFeedEntry(
11011101
environmentId={props.environmentId}
11021102
projectedItem={entry.activities[0]!.projectedItem}
11031103
iconColor={iconSubtleColor}
1104+
surfaceColor={props.screenColor}
11041105
/>
11051106
);
11061107
}
@@ -2546,6 +2547,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
25462547
environmentId={props.environmentId}
25472548
endpoints={props.pendingHandoff}
25482549
iconColor={iconSubtleColor}
2550+
surfaceColor={screenColor}
25492551
label="Context handoff pending"
25502552
/>
25512553
) : null

‎apps/mobile/src/features/threads/thread-handoff-row.tsx‎

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import * as Dialog from "../../components/ui/Dialog";
22
import { useAtomValue } from "@effect/atom-react";
33
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
44
import { resolveHandoffEndpoints, type HandoffEndpoints } from "@t3tools/client-runtime/handoff";
5-
import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display";
5+
import {
6+
normalizeProviderAccentColor,
7+
resolveProviderInstanceDisplayName,
8+
shouldShowInstanceBadge,
9+
} from "@t3tools/client-runtime/state/provider-instance-display";
610
import type {
711
EnvironmentId,
812
OrchestrationV2ProjectedTurnItem,
13+
ProviderInstanceConfig,
914
ProviderInstanceId,
1015
ServerProvider,
1116
} from "@t3tools/contracts";
@@ -14,7 +19,7 @@ import { useMemo } from "react";
1419
import { Pressable, View, type ColorValue } from "react-native";
1520
import { AppText as Text } from "../../components/ui/AppText";
1621
import { SymbolView } from "../../components/ui/AppSymbol";
17-
import { ProviderIcon } from "../../components/ProviderIcon";
22+
import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon";
1823
import { environmentThreadDetails } from "../../state/threads";
1924
import { serverEnvironment } from "../../state/server";
2025
import { ThreadContextDivider } from "./thread-context-divider";
@@ -39,6 +44,7 @@ export function ThreadHandoffRow(props: {
3944
environmentId: EnvironmentId;
4045
projectedItem: OrchestrationV2ProjectedTurnItem;
4146
iconColor: ColorValue;
47+
surfaceColor: string;
4248
}) {
4349
const { item } = props.projectedItem;
4450
const endpointsAtom = useMemo(
@@ -53,6 +59,7 @@ export function ThreadHandoffRow(props: {
5359
environmentId={props.environmentId}
5460
endpoints={endpoints}
5561
iconColor={color}
62+
surfaceColor={props.surfaceColor}
5663
label="Context handoff"
5764
failed={item.status === "failed"}
5865
/>
@@ -63,11 +70,17 @@ export function ThreadHandoffDivider(props: {
6370
environmentId: EnvironmentId;
6471
endpoints: HandoffEndpoints;
6572
iconColor: ColorValue;
73+
surfaceColor: string;
6674
label: string;
6775
failed?: boolean;
6876
}) {
6977
const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId));
7078
const { endpoints } = props;
79+
const endpointProps = {
80+
providers: config?.providers ?? [],
81+
providerInstances: config?.settings.providerInstances,
82+
surfaceColor: props.surfaceColor,
83+
};
7184
return (
7285
<ThreadContextDivider
7386
label={props.label}
@@ -81,7 +94,7 @@ export function ThreadHandoffDivider(props: {
8194
key={`${endpoint.instanceId}:${endpoint.model ?? ""}`}
8295
className="max-w-full flex-row items-center"
8396
>
84-
<HandoffEndpoint {...endpoint} providers={config?.providers ?? []} />
97+
<HandoffEndpoint {...endpoint} {...endpointProps} />
8598
{index < endpoints.from.length - 1 ? (
8699
<Text accessible={false} className="text-xs text-foreground-muted">
87100
,
@@ -92,7 +105,7 @@ export function ThreadHandoffDivider(props: {
92105
{endpoints.from.length > 0 ? (
93106
<SymbolView name="arrow.right" size={12} tintColor={props.iconColor} />
94107
) : null}
95-
<HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />
108+
<HandoffEndpoint {...endpoints.to} {...endpointProps} />
96109
</View>
97110
</ThreadContextDivider>
98111
);
@@ -102,6 +115,8 @@ function HandoffEndpoint(props: {
102115
instanceId: ProviderInstanceId;
103116
model?: string | undefined;
104117
providers: ReadonlyArray<ServerProvider>;
118+
providerInstances: Readonly<Record<string, ProviderInstanceConfig>> | undefined;
119+
surfaceColor: string;
105120
}) {
106121
const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId);
107122
const model = provider?.models.find((candidate) => candidate.slug === props.model);
@@ -110,12 +125,36 @@ function HandoffEndpoint(props: {
110125
model?.name ??
111126
props.model ??
112127
(provider ? resolveProviderInstanceDisplayName(provider) : props.instanceId);
128+
const accentColor = normalizeProviderAccentColor(provider?.accentColor);
129+
// Same account badge as the thread rows: a handoff between two accounts of
130+
// one provider would otherwise show the same glyph on both sides. Settings
131+
// carry each ACP instance's agent, which tells its glyph apart.
132+
const badgeEntry = (candidate: ServerProvider) => {
133+
const config = props.providerInstances?.[candidate.instanceId]?.config;
134+
// A local ACP command has no registry agent, as on web.
135+
const agentId =
136+
typeof config === "object" && config && Reflect.get(config, "source") !== "local"
137+
? Reflect.get(config, "agentId")
138+
: null;
139+
return {
140+
driverKind: candidate.driver,
141+
...(typeof agentId === "string" && agentId.trim()
142+
? { acpRegistryAgentId: agentId.trim() }
143+
: {}),
144+
};
145+
};
146+
const showBadge =
147+
provider !== undefined &&
148+
shouldShowInstanceBadge(
149+
{ ...badgeEntry(provider), accentColor },
150+
props.providers.map(badgeEntry),
151+
);
113152
return (
114153
<Pressable
115154
accessibilityRole="button"
116155
accessibilityLabel={label}
117156
accessibilityHint="Show provider account"
118-
className="min-h-6 max-w-full flex-row items-center justify-center gap-1"
157+
className="min-h-6 max-w-full flex-row items-center justify-center gap-1.5"
119158
hitSlop={{ top: 8, bottom: 8 }}
120159
onPress={() =>
121160
Dialog.alert(
@@ -124,11 +163,23 @@ function HandoffEndpoint(props: {
124163
)
125164
}
126165
>
127-
<ProviderIcon
128-
provider={provider?.driver ?? props.instanceId}
129-
iconUrl={provider?.iconUrl}
130-
size={12}
131-
/>
166+
{showBadge && provider !== undefined ? (
167+
<ProviderInstanceIcon
168+
provider={provider.driver}
169+
iconUrl={provider.iconUrl}
170+
size={14}
171+
displayName={resolveProviderInstanceDisplayName(provider)}
172+
accentColor={accentColor}
173+
showBadge
174+
surfaceColor={props.surfaceColor}
175+
/>
176+
) : (
177+
<ProviderIcon
178+
provider={provider?.driver ?? props.instanceId}
179+
iconUrl={provider?.iconUrl}
180+
size={12}
181+
/>
182+
)}
132183
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
133184
{label}
134185
</Text>

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7990,6 +7990,7 @@ export default function ChatView(props: ChatViewProps) {
79907990
<>
79917991
<div className="chat-content-lane">
79927992
<HandoffRow
7993+
environmentId={environmentId}
79937994
endpoints={pendingHandoff}
79947995
providers={providerStatuses}
79957996
pending

‎apps/web/src/components/chat/V2LifecycleRow.tsx‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ import {
4242
} from "lucide-react";
4343

4444
import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
45-
import { getProviderInstanceEntry } from "../../providerInstances";
45+
import {
46+
applyProviderInstanceSettings,
47+
deriveProviderInstanceEntries,
48+
shouldShowInstanceBadge,
49+
type ProviderInstanceEntry,
50+
} from "../../providerInstances";
4651
import { formatShortTimestamp } from "../../timestampFormat";
4752
import { getTriggerDisplayModelName } from "./providerIconUtils";
4853
import { ProviderInstanceIcon, providerTextColor } from "./ProviderInstanceIcon";
@@ -575,16 +580,21 @@ function EnvironmentHandoffRow({
575580
}) {
576581
const providers =
577582
useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
578-
return <HandoffRow {...props} providers={providers} />;
583+
return <HandoffRow {...props} environmentId={environmentId} providers={providers} />;
579584
}
580585

581586
export function HandoffRow(props: {
587+
readonly environmentId: EnvironmentId;
582588
readonly endpoints: HandoffEndpoints;
583589
readonly providers: ReadonlyArray<ServerProvider>;
584590
readonly pending?: boolean;
585591
readonly failed?: boolean;
586592
}) {
587593
const { from: fromEndpoints, to } = props.endpoints;
594+
const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId));
595+
// Settings carry each ACP instance's agent, which tells its glyph apart.
596+
const derived = deriveProviderInstanceEntries(props.providers);
597+
const entries = settings ? applyProviderInstanceSettings(derived, settings, null) : derived;
588598
return (
589599
<TimelineSystemDivider
590600
label={props.pending ? "Context handoff pending" : "Context handoff"}
@@ -599,7 +609,7 @@ export function HandoffRow(props: {
599609
className="inline-flex min-w-0 items-center"
600610
>
601611
<HandoffEndpoint
602-
providers={props.providers}
612+
entries={entries}
603613
instanceId={endpoint.instanceId}
604614
model={endpoint.model}
605615
/>
@@ -613,23 +623,22 @@ export function HandoffRow(props: {
613623
{fromEndpoints.length > 0 ? (
614624
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
615625
) : null}
616-
<HandoffEndpoint
617-
providers={props.providers}
618-
instanceId={to.instanceId}
619-
model={to.model}
620-
/>
626+
<HandoffEndpoint entries={entries} instanceId={to.instanceId} model={to.model} />
621627
</span>
622628
}
623629
/>
624630
);
625631
}
626632

627633
function HandoffEndpoint(props: {
628-
readonly providers: ReadonlyArray<ServerProvider>;
634+
readonly entries: ReadonlyArray<ProviderInstanceEntry>;
629635
readonly instanceId: ProviderInstanceId;
630636
readonly model?: string | undefined;
631637
}) {
632-
const entry = getProviderInstanceEntry(props.providers, props.instanceId);
638+
const entry = props.entries.find((candidate) => candidate.instanceId === props.instanceId);
639+
// Same account badge as the sidebar: a handoff between two accounts of one
640+
// provider would otherwise show the same glyph on both sides.
641+
const showBadge = entry !== undefined && shouldShowInstanceBadge(entry, props.entries);
633642
const model = props.model?.trim();
634643
const providerModel =
635644
model === undefined || model.length === 0
@@ -650,14 +659,19 @@ function HandoffEndpoint(props: {
650659
render={
651660
<span
652661
tabIndex={0}
653-
className="inline-flex min-w-0 items-center gap-1 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
662+
className="inline-flex min-w-0 items-center gap-1.5 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
654663
>
655664
<ProviderInstanceIcon
656665
driverKind={entry?.driverKind ?? ProviderDriverKind.make(props.instanceId)}
657666
displayName={entry?.displayName ?? props.instanceId}
667+
accentColor={entry?.accentColor}
658668
acpRegistryAgentId={entry?.acpRegistryAgentId}
659669
acpRegistryIconUrl={entry?.acpRegistryIconUrl}
660-
iconClassName="size-3"
670+
showBadge={showBadge}
671+
// Glyph dims, badge stays saturated; sized like the sidebar row.
672+
iconClassName={cn("size-3.5", showBadge && "opacity-60")}
673+
badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs"
674+
indicatorBackground="var(--background)"
661675
/>
662676
<span
663677
className={cn("truncate font-medium", labelColor.className)}

0 commit comments

Comments
 (0)