Skip to content
Closed
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/web/src/components/chat/ModelPickerContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{showSidebar && (
<ModelPickerSidebar
selectedInstanceId={selectedInstanceId}
activeInstanceId={props.activeInstanceId}
onSelectInstance={handleSelectInstance}
onFocusSearch={focusSearchInput}
instanceEntries={sidebarInstanceEntries}
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/chat/ModelPickerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const PICKER_TOOLTIP_SIDE_OFFSET = 8;

export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
selectedInstanceId: ProviderInstanceId | "favorites";
/** The instance the thread runs on; it stays marked while another one is browsed. */
activeInstanceId?: ProviderInstanceId;
onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;
onFocusSearch: () => void;
instanceEntries: ReadonlyArray<ProviderInstanceEntry>;
Expand Down Expand Up @@ -131,14 +133,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
const isDisabled =
(isUnavailable && !unavailableSelectionIsReachable) || isContextDisabled;
const isSelected = props.selectedInstanceId === entry.instanceId;
const isActive = props.activeInstanceId === entry.instanceId;
const isHovered = hoveredInstanceId === entry.instanceId;
const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries);

const tooltip = isUnavailable
? describeUnavailableInstance(entry)
: isContextDisabled
? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName)
: entry.displayName;
: isActive
? `${entry.displayName} · Active`
: entry.displayName;

const button = (
<Toolbar.Button
Expand All @@ -159,6 +164,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
disabled={isDisabled}
focusableWhenDisabled={!isDisabled}
aria-pressed={isSelected}
aria-current={isActive ? "true" : undefined}
type="button"
aria-label={isUnavailable || isContextDisabled ? tooltip : entry.displayName}
>
Expand All @@ -180,6 +186,13 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
}
{...(entry.accentColor ? { badgeClassName: "h-3 min-w-3 px-0.5 text-5xs" } : {})}
/>
{isActive ? (
<span
aria-hidden
data-model-picker-active-instance
className="absolute top-1 right-1 z-40 size-2 rounded-full bg-success ring-2 ring-popover"
/>
) : null}
</Toolbar.Button>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ it("keeps each registry image when adding a model from another ACP agent", async
await vi.waitFor(() => expect(imageWidths()).toEqual([11]));
await screen.getByRole("button", { name: "First model", exact: true }).click();
await page.getByRole("button", { name: "Second agent", exact: true }).click();
await expect
.element(page.getByRole("button", { name: "First agent", exact: true }))
.toHaveAttribute("aria-current", "true");
await expect
.element(page.getByRole("button", { name: "Second agent", exact: true }))
.not.toHaveAttribute("aria-current");
await userEvent.keyboard("{Shift>}");
try {
await page.getByRole("option", { name: /Second model/ }).click();
Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
} from "@t3tools/contracts";
import { createContext, memo, type ReactNode, useRef, useState } from "react";
import { Badge } from "../ui/badge";
import { Kbd } from "../ui/kbd";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { cn } from "~/lib/utils";
import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard";
import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
import { ChatGptSharingControl } from "./ChatGptSharingControl";
import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
Expand Down Expand Up @@ -228,6 +230,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
const allModelNames = selectedEntries
? selectedEntries.map((selection) => selection.fullLabel).join(", ") || "Choose models"
: undefined;
// Only when same-driver instances could be confused, e.g. one Claude instance per account.
const activeInstanceLabel =
showInstanceBadge && !selectedEntries ? activeEntry?.displayName : undefined;
const triggerTooltipContent = [
props.triggerLabel ?? allModelNames ?? triggerLabel,
props.triggerDetail,
Expand Down Expand Up @@ -309,6 +314,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
aria-label={[
props.triggerAriaLabel ?? props.triggerLabel ?? allModelNames ?? triggerLabel,
props.triggerDetail,
activeInstanceLabel,
]
.filter(Boolean)
.join(", ")}
Expand Down Expand Up @@ -387,12 +393,42 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
/>
</span>
) : null}
{props.isComposerOwned ? (
{props.isComposerOwned && !activeInstanceLabel ? (
<span {...triggerTextProps}>{triggerText}</span>
) : (
<Tooltip>
<TooltipTrigger render={<span {...triggerTextProps} />}>{triggerText}</TooltipTrigger>
<TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
{activeInstanceLabel && activeEntry ? (
// Same card as the agents panel, so the thread's instance reads the same way.
<ThreadHoverCardPopup side="top">
<ThreadHoverCard title={props.triggerLabel ?? triggerLabel}>
<div className="flex min-w-0 items-center gap-2">
<ProviderInstanceIcon
driverKind={activeEntry.driverKind}
displayName={activeEntry.displayName}
accentColor={activeEntry.accentColor}
acpRegistryAgentId={activeEntry.acpRegistryAgentId}
acpRegistryIconUrl={activeEntry.acpRegistryIconUrl}
showBadge
badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs"
className="size-4"
iconClassName="size-4"
/>
<span className="min-w-0 truncate text-foreground/75">
{activeInstanceLabel}
</span>
</div>
{props.triggerDetail || shortcutLabel ? (
<div className="flex min-w-0 items-center justify-between gap-4">
<span className="min-w-0 truncate">{props.triggerDetail}</span>
{shortcutLabel ? <Kbd>{shortcutLabel}</Kbd> : null}
</div>
) : null}
</ThreadHoverCard>
</ThreadHoverCardPopup>
) : (
<TooltipPopup side="top">{triggerTooltipContent}</TooltipPopup>
)}
</Tooltip>
)}
{props.triggerSuffix}
Expand Down
Loading