Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,7 @@ function renderFeedEntry(
environmentId={props.environmentId}
projectedItem={entry.activities[0]!.projectedItem}
iconColor={iconSubtleColor}
surfaceColor={props.screenColor}
/>
);
}
Expand Down
69 changes: 59 additions & 10 deletions apps/mobile/src/features/threads/thread-handoff-row.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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,
ProviderInstanceConfig,
OrchestrationV2ProjectedTurnItem,
ProviderInstanceId,
ServerProvider,
Expand All @@ -13,7 +18,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";
Expand All @@ -38,6 +43,7 @@ export function ThreadHandoffRow(props: {
environmentId: EnvironmentId;
projectedItem: OrchestrationV2ProjectedTurnItem;
iconColor: ColorValue;
surfaceColor: string;
}) {
const { item } = props.projectedItem;
const endpointsAtom = useMemo(
Expand All @@ -48,6 +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 ?? [],
providerInstances: config?.settings.providerInstances,
surfaceColor: props.surfaceColor,
};
return (
<ThreadContextDivider
label="Context handoff"
Expand All @@ -63,13 +74,13 @@ export function ThreadHandoffRow(props: {
,
</Text>
) : null}
<HandoffEndpoint {...endpoint} providers={config?.providers ?? []} />
<HandoffEndpoint {...endpoint} {...endpointProps} />
</Fragment>
))}
{endpoints.from.length > 0 ? (
<SymbolView name="arrow.right" size={12} tintColor={color} />
) : null}
<HandoffEndpoint {...endpoints.to} providers={config?.providers ?? []} />
<HandoffEndpoint {...endpoints.to} {...endpointProps} />
</View>
</ThreadContextDivider>
);
Expand All @@ -79,6 +90,8 @@ function HandoffEndpoint(props: {
instanceId: ProviderInstanceId;
model?: string | undefined;
providers: ReadonlyArray<ServerProvider>;
providerInstances: Readonly<Record<string, ProviderInstanceConfig>> | undefined;
surfaceColor: string;
}) {
const provider = props.providers.find((candidate) => candidate.instanceId === props.instanceId);
const model = provider?.models.find((candidate) => candidate.slug === props.model);
Expand All @@ -87,12 +100,36 @@ 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. Settings
// carry each ACP instance's agent, which tells its glyph apart.
const badgeEntry = (candidate: ServerProvider) => {
const config = props.providerInstances?.[candidate.instanceId]?.config;
// A local ACP command has no registry agent, as on web.
const agentId =
typeof config === "object" && config && Reflect.get(config, "source") !== "local"
? Reflect.get(config, "agentId")
: null;
return {
driverKind: candidate.driver,
...(typeof agentId === "string" && agentId.trim()
? { acpRegistryAgentId: agentId.trim() }
: {}),
};
};
const showBadge =
provider !== undefined &&
shouldShowInstanceBadge(
{ ...badgeEntry(provider), accentColor },
props.providers.map(badgeEntry),
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="Show provider account"
className="min-h-6 max-w-full flex-row items-center justify-center gap-1"
className="min-h-6 max-w-full flex-row items-center justify-center gap-1.5"
hitSlop={{ top: 8, bottom: 8 }}
onPress={() =>
Alert.alert(
Expand All @@ -101,11 +138,23 @@ function HandoffEndpoint(props: {
)
}
>
<ProviderIcon
provider={provider?.driver ?? props.instanceId}
iconUrl={provider?.iconUrl}
size={12}
/>
{showBadge && provider !== undefined ? (
<ProviderInstanceIcon
provider={provider.driver}
iconUrl={provider.iconUrl}
size={14}
displayName={resolveProviderInstanceDisplayName(provider)}
accentColor={accentColor}
showBadge
surfaceColor={props.surfaceColor}
/>
) : (
<ProviderIcon
provider={provider?.driver ?? props.instanceId}
iconUrl={provider?.iconUrl}
size={12}
/>
)}
<Text numberOfLines={1} className="shrink text-xs text-foreground-muted">
{label}
</Text>
Expand Down
29 changes: 25 additions & 4 deletions apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import {
} from "lucide-react";

import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
import { getProviderInstanceEntry } from "../../providerInstances";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
shouldShowInstanceBadge,
} from "../../providerInstances";
import { useEnvironmentSettings } from "../../hooks/useSettings";
import { formatShortTimestamp } from "../../timestampFormat";
import { getTriggerDisplayModelName } from "./providerIconUtils";
import { ProviderInstanceIcon, providerTextColorClassName } from "./ProviderInstanceIcon";
Expand Down Expand Up @@ -137,6 +142,7 @@ export function V2LifecycleRow(props: {
</span>
) : null}
<HandoffEndpoint
environmentId={props.environmentId}
providers={props.providerStatuses}
instanceId={endpoint.instanceId}
model={endpoint.model}
Expand All @@ -147,6 +153,7 @@ export function V2LifecycleRow(props: {
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0" />
) : null}
<HandoffEndpoint
environmentId={props.environmentId}
providers={props.providerStatuses}
instanceId={item.toProviderInstanceId}
model={to.model}
Expand Down Expand Up @@ -556,11 +563,20 @@ function SubagentTimelineTooltip(
}

function HandoffEndpoint(props: {
readonly environmentId: EnvironmentId;
readonly providers: ReadonlyArray<ServerProvider>;
readonly instanceId: ProviderInstanceId;
readonly model?: string | undefined;
}) {
const entry = getProviderInstanceEntry(props.providers, props.instanceId);
// Settings carry each ACP instance's agent, which tells its glyph apart.
const entries = applyProviderInstanceSettings(
deriveProviderInstanceEntries(props.providers),
useEnvironmentSettings(props.environmentId),
);
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
Expand All @@ -578,14 +594,19 @@ function HandoffEndpoint(props: {
render={
<span
tabIndex={0}
className="inline-flex min-w-0 items-center gap-1 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="inline-flex min-w-0 items-center gap-1.5 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ProviderInstanceIcon
driverKind={entry?.driverKind ?? ProviderDriverKind.make(props.instanceId)}
displayName={entry?.displayName ?? props.instanceId}
accentColor={entry?.accentColor}
acpRegistryAgentId={entry?.acpRegistryAgentId}
acpRegistryIconUrl={entry?.acpRegistryIconUrl}
iconClassName="size-3"
showBadge={showBadge}
// Glyph dims, badge stays saturated; sized like the sidebar row.
iconClassName={cn("size-3.5", showBadge && "opacity-60")}
badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs"
indicatorBackground="var(--background)"
/>
<span
className={cn(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/providerInstances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export function sortProviderInstanceEntries(
* Look up a single instance entry by exact `instanceId`. Missing snapshots
* are not inferred from driver kind in UI routing code.
*/
export function getProviderInstanceEntry(
function getProviderInstanceEntry(
providers: ReadonlyArray<ServerProvider>,
instanceId: ProviderInstanceId,
): ProviderInstanceEntry | undefined {
Expand Down
Loading