mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506630f951 | ||
|
|
96d05237a1 | ||
|
|
26d32c14a2 | ||
|
|
ff337176e3 |
@@ -8,6 +8,7 @@
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
@@ -237,6 +238,8 @@
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
@@ -1325,6 +1328,8 @@
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
Generated
+56
@@ -11,6 +11,7 @@
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
@@ -1424,6 +1425,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
|
||||
@@ -46,7 +46,6 @@ import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
const INITIAL_VISIBLE_SESSIONS = 160;
|
||||
const VISIBLE_SESSIONS_INCREMENT = 160;
|
||||
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||
const ACTION_MENU_ITEM_CLASS = "grid w-[7.75rem] grid-cols-[1rem_minmax(0,1fr)] items-center gap-2";
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
@@ -337,7 +336,6 @@ export const ChatList = memo(function ChatList({
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onTogglePin(s.key)}
|
||||
className={ACTION_MENU_ITEM_CLASS}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PinOff className="h-4 w-4 shrink-0" />
|
||||
@@ -348,14 +346,12 @@ export const ChatList = memo(function ChatList({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onRequestRename(s.key, title)}
|
||||
className={ACTION_MENU_ITEM_CLASS}
|
||||
>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
{t("chat.rename")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onToggleArchive(s.key)}
|
||||
className={ACTION_MENU_ITEM_CLASS}
|
||||
>
|
||||
{isArchived ? (
|
||||
<ArchiveRestore className="h-4 w-4 shrink-0" />
|
||||
@@ -365,13 +361,10 @@ export const ChatList = memo(function ChatList({
|
||||
{isArchived ? t("chat.unarchive") : t("chat.archive")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
onSelect={() => {
|
||||
window.setTimeout(() => onRequestDelete(s.key, title), 0);
|
||||
}}
|
||||
className={cn(
|
||||
ACTION_MENU_ITEM_CLASS,
|
||||
"text-destructive focus:text-destructive",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
{t("chat.delete")}
|
||||
@@ -472,7 +465,7 @@ function ProjectGroupHeader({
|
||||
portalContainer={actionMenuPortalContainer}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onSelect={onRequestRename} className={ACTION_MENU_ITEM_CLASS}>
|
||||
<DropdownMenuItem onSelect={onRequestRename}>
|
||||
<Pencil className="h-4 w-4 shrink-0" />
|
||||
{t("chat.rename")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -84,6 +84,10 @@ import {
|
||||
ChannelSetupPanel,
|
||||
} from "@/components/settings/channels/ChannelSetupPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ComboboxOption,
|
||||
useComboboxNavigation,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -100,6 +104,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { isLoopbackHost } from "@/lib/network";
|
||||
import {
|
||||
@@ -2537,7 +2546,7 @@ function SettingsSidebar({
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)] rounded-[16px] p-1.5"
|
||||
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)]"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
@@ -2547,7 +2556,7 @@ function SettingsSidebar({
|
||||
aria-current={active ? "page" : undefined}
|
||||
onSelect={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"flex h-10 cursor-default items-center gap-2.5 rounded-[11px] px-2.5 text-[13px] font-medium",
|
||||
"flex h-10 cursor-default items-center gap-2.5 px-2.5 text-[13px] font-medium",
|
||||
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
|
||||
)}
|
||||
>
|
||||
@@ -4748,11 +4757,11 @@ function ProvidersSettings({
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto rounded-[20px] border-border bg-popover p-1.5 shadow-none scrollbar-thin scrollbar-track-transparent"
|
||||
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={beginCustomProviderCreation}
|
||||
className="flex min-h-[54px] cursor-default items-center gap-3 rounded-[14px] px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||
>
|
||||
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
@@ -4769,7 +4778,7 @@ function ProvidersSettings({
|
||||
onToggleProvider(provider.name);
|
||||
}
|
||||
}}
|
||||
className="flex min-h-[54px] cursor-default items-center gap-3 rounded-[14px] px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider.name}
|
||||
@@ -7472,15 +7481,19 @@ function CliAppsCatalogRow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
|
||||
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.cliApps.test", "Test CLI")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<RotateCcw aria-hidden />
|
||||
{tx("settings.cliApps.update", "Update CLI")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onAction("uninstall", app.name)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -7604,17 +7617,21 @@ function McpAppsCatalogRow({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<PlayCircle aria-hidden />
|
||||
{tx("settings.mcp.test", "Test")}
|
||||
</DropdownMenuItem>
|
||||
{toolNames.length ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
|
||||
<SlidersHorizontal className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<SlidersHorizontal aria-hidden />
|
||||
{tx("settings.mcp.toolScope", "Tools")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("remove", preset.name)}>
|
||||
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
<DropdownMenuItem
|
||||
tone="destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onAction("remove", preset.name)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
{tx("settings.mcp.remove", "Remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -8821,13 +8838,35 @@ function TimezonePicker({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const options = useMemo(() => timezoneOptions(value), [value]);
|
||||
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]);
|
||||
const optionValues = useMemo(
|
||||
() => filteredOptions.map((option) => option.name),
|
||||
[filteredOptions],
|
||||
);
|
||||
const chooseTimezone = (timezone: string) => {
|
||||
onChange(timezone);
|
||||
setOpen(false);
|
||||
};
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: optionValues,
|
||||
selectedValue: value,
|
||||
onSelect: chooseTimezone,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => !open && setQuery("")}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -8839,8 +8878,8 @@ function TimezonePicker({
|
||||
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[340px] max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
@@ -8851,27 +8890,29 @@ function TimezonePicker({
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
{...navigation.inputProps}
|
||||
placeholder={tx("settings.timezone.search", "Search timezone")}
|
||||
aria-label={tx("settings.timezone.search", "Search timezone")}
|
||||
className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||
data-testid="timezone-picker-list"
|
||||
>
|
||||
{filteredOptions.length ? (
|
||||
filteredOptions.map((option) => {
|
||||
{filteredOptions.length ? (
|
||||
<div
|
||||
{...navigation.listProps}
|
||||
aria-label={tx("settings.timezone.select", "Select timezone")}
|
||||
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||
data-testid="timezone-picker-list"
|
||||
>
|
||||
{filteredOptions.map((option) => {
|
||||
const selected = option.name === value;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<ComboboxOption
|
||||
key={option.name}
|
||||
onSelect={() => onChange(option.name)}
|
||||
{...navigation.getOptionProps(option.name)}
|
||||
className={cn(
|
||||
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]",
|
||||
"focus:bg-muted/85 focus:text-foreground",
|
||||
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
selected && "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
|
||||
@@ -8881,17 +8922,21 @@ function TimezonePicker({
|
||||
</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-5 text-center text-[12px] text-muted-foreground">
|
||||
{tx("settings.timezone.empty", "No matching timezones.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role="status"
|
||||
className="px-3 py-5 text-center text-[12px] text-muted-foreground"
|
||||
data-testid="timezone-picker-list"
|
||||
>
|
||||
{tx("settings.timezone.empty", "No matching timezones.")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8947,8 +8992,7 @@ function ProviderPicker({
|
||||
key={provider.name}
|
||||
onSelect={() => onChange(provider.name)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]",
|
||||
"focus:bg-muted/85 focus:text-foreground",
|
||||
"flex cursor-default items-center justify-between gap-2 text-[13px]",
|
||||
selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
)}
|
||||
>
|
||||
@@ -9021,16 +9065,22 @@ function ModelIdPicker({
|
||||
!hasStaticModels &&
|
||||
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const providerModels: ProviderModelsPayload["models"] = hasStaticModels
|
||||
? (models?.map((id) => ({ id })) ?? [])
|
||||
: (payload?.models ?? []);
|
||||
const visibleModels = providerModels
|
||||
.filter((model) => {
|
||||
if (!normalizedQuery) return true;
|
||||
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
|
||||
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||
})
|
||||
.slice(0, 80);
|
||||
const providerModels: ProviderModelsPayload["models"] = useMemo(
|
||||
() => hasStaticModels
|
||||
? (models?.map((id) => ({ id })) ?? [])
|
||||
: (payload?.models ?? []),
|
||||
[hasStaticModels, models, payload?.models],
|
||||
);
|
||||
const visibleModels = useMemo(
|
||||
() => providerModels
|
||||
.filter((model) => {
|
||||
if (!normalizedQuery) return true;
|
||||
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
|
||||
.some((field) => field.toLowerCase().includes(normalizedQuery));
|
||||
})
|
||||
.slice(0, 80),
|
||||
[normalizedQuery, providerModels],
|
||||
);
|
||||
const isCatalog = payload?.catalog_kind === "catalog";
|
||||
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
|
||||
const hasDeferredSearchQuery =
|
||||
@@ -9046,6 +9096,9 @@ function ModelIdPicker({
|
||||
const customCandidate = query.trim();
|
||||
const allowCustomModel = !providerRequiresConfiguration;
|
||||
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
|
||||
const showCustomModel = Boolean(
|
||||
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
|
||||
);
|
||||
const providerModelCount = payload?.model_count ?? providerModels.length;
|
||||
const modelUnconfigured = !value.trim() || !providerConfigured;
|
||||
|
||||
@@ -9084,18 +9137,31 @@ function ModelIdPicker({
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
};
|
||||
const navigationValues = useMemo(
|
||||
() => [
|
||||
...(showModels ? visibleModels.map((model) => model.id) : []),
|
||||
...(showCustomModel ? [customCandidate] : []),
|
||||
],
|
||||
[customCandidate, showCustomModel, showModels, visibleModels],
|
||||
);
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: navigationValues,
|
||||
selectedValue: value,
|
||||
onSelect: selectModel,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
const renderModelRow = (
|
||||
model: ProviderModelsPayload["models"][number],
|
||||
options: { selected?: boolean } = {},
|
||||
) => (
|
||||
<DropdownMenuItem
|
||||
<ComboboxOption
|
||||
key={model.id}
|
||||
onSelect={() => selectModel(model.id)}
|
||||
{...navigation.getOptionProps(model.id)}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]",
|
||||
"focus:bg-muted/85 focus:text-foreground",
|
||||
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
|
||||
options.selected && "text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -9121,12 +9187,12 @@ function ModelIdPicker({
|
||||
{model.context_window ? <span>{formatContextWindow(model.context_window)}</span> : null}
|
||||
{options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</ComboboxOption>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -9152,8 +9218,8 @@ function ModelIdPicker({
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
|
||||
>
|
||||
@@ -9166,13 +9232,7 @@ function ModelIdPicker({
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter" && allowCustomModel && customCandidate) {
|
||||
event.preventDefault();
|
||||
selectModel(customCandidate);
|
||||
}
|
||||
}}
|
||||
{...navigation.inputProps}
|
||||
placeholder={
|
||||
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
|
||||
}
|
||||
@@ -9228,11 +9288,36 @@ function ModelIdPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showModels && visibleModels.length ? (
|
||||
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent">
|
||||
{visibleModels.map((model) =>
|
||||
renderModelRow(model, { selected: model.id === value }),
|
||||
)}
|
||||
{navigationValues.length ? (
|
||||
<div
|
||||
{...navigation.listProps}
|
||||
aria-label={searchPlaceholder || tx("settings.models.selectModel", "Select model")}
|
||||
className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{showModels
|
||||
? visibleModels.map((model) =>
|
||||
renderModelRow(model, { selected: model.id === value }),
|
||||
)
|
||||
: null}
|
||||
{showCustomModel ? (
|
||||
<>
|
||||
{showModels && visibleModels.length ? (
|
||||
<div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
|
||||
) : null}
|
||||
<ComboboxOption
|
||||
{...navigation.getOptionProps(customCandidate)}
|
||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px]"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{tx("settings.models.useCustomModel", "Use")}{" "}
|
||||
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
||||
</span>
|
||||
</ComboboxOption>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : showModels ? (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
|
||||
@@ -9240,25 +9325,8 @@ function ModelIdPicker({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
|
||||
<>
|
||||
{showModels ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => selectModel(customCandidate)}
|
||||
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85"
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-muted/80 text-muted-foreground">
|
||||
<Pencil className="h-3 w-3" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 truncate">
|
||||
{tx("settings.models.useCustomModel", "Use")}{" "}
|
||||
<span className="font-medium text-foreground">“{customCandidate}”</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
@@ -63,8 +63,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -76,11 +76,11 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
|
||||
>
|
||||
<ListTodo className="h-4 w-4 stroke-[1.75]" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
|
||||
className="w-[min(23rem,calc(100vw-1.5rem))] p-0"
|
||||
>
|
||||
<div className="space-y-3 px-4 py-3.5">
|
||||
<div className="min-w-0">
|
||||
@@ -108,8 +108,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
|
||||
|
||||
{automationContent}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
floatingItemClassName,
|
||||
floatingSurfaceVisualClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -2688,10 +2692,9 @@ function CliAppMentionPalette({
|
||||
aria-label={t("thread.composer.mentions.ariaLabel")}
|
||||
style={{ maxHeight: layout.maxHeight }}
|
||||
className={cn(
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[22px] border",
|
||||
floatingSurfaceVisualClassName,
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
|
||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
"border-border/70 bg-popover p-2 text-popover-foreground shadow-[0_20px_60px_rgba(15,23,42,0.12)]",
|
||||
"dark:border-white/10 dark:shadow-[0_24px_60px_rgba(0,0,0,0.42)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
>
|
||||
@@ -2728,7 +2731,8 @@ function CliAppMentionPalette({
|
||||
onChoose(candidate);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors",
|
||||
floatingItemClassName,
|
||||
"flex min-h-10 w-full items-center gap-2.5 px-2.5 py-1.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-foreground/[0.055] text-foreground"
|
||||
: "text-foreground/90 hover:bg-foreground/[0.04]",
|
||||
@@ -2836,10 +2840,9 @@ function SlashCommandPalette({
|
||||
aria-label={t("thread.composer.slash.ariaLabel")}
|
||||
style={{ maxHeight: layout.maxHeight }}
|
||||
className={cn(
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||
floatingSurfaceVisualClassName,
|
||||
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden",
|
||||
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
|
||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||
)}
|
||||
>
|
||||
@@ -2868,7 +2871,8 @@ function SlashCommandPalette({
|
||||
onChoose(command);
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
|
||||
floatingItemClassName,
|
||||
"flex min-h-[44px] w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
||||
selected
|
||||
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
|
||||
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
|
||||
|
||||
@@ -9,7 +9,16 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type {
|
||||
WorkspaceAccessMode,
|
||||
WorkspaceScopePayload,
|
||||
@@ -134,8 +143,8 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -151,16 +160,21 @@ export function WorkspaceProjectPicker({
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]"
|
||||
className="w-[min(25rem,calc(100vw-2rem))]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
|
||||
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"flex min-h-[48px] w-full cursor-default gap-3 px-3 py-2.5 focus:bg-muted/55",
|
||||
)}
|
||||
>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-[12px] bg-muted text-foreground/80">
|
||||
<Folder className="h-4 w-4" />
|
||||
@@ -174,14 +188,9 @@ export function WorkspaceProjectPicker({
|
||||
</span>
|
||||
</span>
|
||||
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border/45" />
|
||||
<div
|
||||
className="space-y-1.5 px-1.5 py-1.5"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5 px-1.5 py-1.5">
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
@@ -217,8 +226,8 @@ export function WorkspaceProjectPicker({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -323,7 +332,7 @@ function AccessMenuItem({
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-3 rounded-xl px-3 text-[13.5px] font-semibold",
|
||||
"flex h-10 items-center gap-3 px-3 text-[13.5px] font-semibold",
|
||||
warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComboboxNavigationOptions {
|
||||
open: boolean;
|
||||
values: readonly string[];
|
||||
selectedValue?: string;
|
||||
onSelect: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function useComboboxNavigation({
|
||||
open,
|
||||
values,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: ComboboxNavigationOptions) {
|
||||
const listboxId = React.useId();
|
||||
const [activeValue, setActiveValue] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setActiveValue(null);
|
||||
return;
|
||||
}
|
||||
setActiveValue((current) => {
|
||||
if (current && values.includes(current)) return current;
|
||||
if (selectedValue && values.includes(selectedValue)) return selectedValue;
|
||||
return values[0] ?? null;
|
||||
});
|
||||
}, [open, selectedValue, values]);
|
||||
|
||||
const activeIndex = activeValue ? values.indexOf(activeValue) : -1;
|
||||
const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeOptionId) return;
|
||||
const option = document.getElementById(activeOptionId);
|
||||
option?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeOptionId]);
|
||||
|
||||
const move = (offset: number) => {
|
||||
if (!values.length) return;
|
||||
const nextIndex = activeIndex < 0
|
||||
? offset > 0 ? 0 : values.length - 1
|
||||
: (activeIndex + offset + values.length) % values.length;
|
||||
setActiveValue(values[nextIndex]);
|
||||
};
|
||||
|
||||
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
if (values.length) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
if (values.length) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (activeValue) {
|
||||
event.preventDefault();
|
||||
onSelect(activeValue);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const expanded = open && values.length > 0;
|
||||
const inputProps = {
|
||||
role: "combobox" as const,
|
||||
"aria-autocomplete": "list" as const,
|
||||
"aria-controls": expanded ? listboxId : undefined,
|
||||
"aria-expanded": expanded,
|
||||
"aria-activedescendant": expanded ? activeOptionId : undefined,
|
||||
onKeyDown: onInputKeyDown,
|
||||
};
|
||||
|
||||
const listProps = {
|
||||
id: listboxId,
|
||||
role: "listbox" as const,
|
||||
};
|
||||
|
||||
const getOptionProps = (value: string) => {
|
||||
const index = values.indexOf(value);
|
||||
return {
|
||||
id: `${listboxId}-option-${index}`,
|
||||
role: "option" as const,
|
||||
"aria-selected": value === activeValue,
|
||||
"data-highlighted": value === activeValue ? "" : undefined,
|
||||
tabIndex: -1,
|
||||
onPointerMove: () => setActiveValue(value),
|
||||
onClick: () => onSelect(value),
|
||||
};
|
||||
};
|
||||
|
||||
return { inputProps, listProps, getOptionProps };
|
||||
}
|
||||
|
||||
const ComboboxOption = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, type = "button", ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
"w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ComboboxOption.displayName = "ComboboxOption";
|
||||
|
||||
export { ComboboxOption };
|
||||
@@ -2,6 +2,12 @@ import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import {
|
||||
floatingItemClassName,
|
||||
floatingItemFocusClassName,
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
@@ -11,11 +17,8 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const menuContentClassName =
|
||||
"z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem] overflow-x-hidden overflow-y-auto overscroll-contain rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl scrollbar-thin scrollbar-track-transparent dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
|
||||
|
||||
const menuItemClassName =
|
||||
"relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors focus:bg-foreground/[0.055] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-white/[0.08]";
|
||||
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
@@ -46,7 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuContentClassName,
|
||||
floatingSurfaceClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -68,8 +72,9 @@ const DropdownMenuContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
menuContentClassName,
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -82,13 +87,15 @@ const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
tone?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
>(({ className, inset, tone = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
menuItemClassName,
|
||||
inset && "pl-8",
|
||||
tone === "destructive" && "text-destructive focus:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const floatingSurfaceVisualClassName =
|
||||
"rounded-[18px] border border-border/65 bg-popover/96 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur-xl dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]";
|
||||
|
||||
export const floatingSurfaceClassName =
|
||||
`${floatingSurfaceVisualClassName} z-50 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent`;
|
||||
|
||||
export const floatingSurfaceMotionClassName =
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0";
|
||||
|
||||
export const floatingItemClassName =
|
||||
"relative flex min-h-8 select-none items-center gap-2 rounded-[12px] px-2.5 py-2 text-[13px] outline-none transition-colors [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0";
|
||||
|
||||
export const floatingItemFocusClassName =
|
||||
"focus:bg-foreground/[0.055] focus:text-foreground dark:focus:bg-white/[0.08]";
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
} from "@/components/ui/floating-surface";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
interface PopoverContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
|
||||
portalContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
PopoverContentProps
|
||||
>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
floatingSurfaceClassName,
|
||||
floatingSurfaceMotionClassName,
|
||||
"max-h-[min(var(--radix-popover-content-available-height),28rem)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
@@ -1715,6 +1716,7 @@ describe("App layout", () => {
|
||||
});
|
||||
|
||||
it("opens the settings view from the sidebar footer", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSessions = [
|
||||
{
|
||||
key: "websocket:chat-a",
|
||||
@@ -1729,6 +1731,18 @@ describe("App layout", () => {
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href === "/api/settings/api-service") {
|
||||
return jsonResponse({
|
||||
installed: false,
|
||||
running: false,
|
||||
managed: false,
|
||||
host: "127.0.0.1",
|
||||
port: 8900,
|
||||
timeout: 120,
|
||||
endpoint: "http://127.0.0.1:8900/v1",
|
||||
command: "nanobot serve",
|
||||
});
|
||||
}
|
||||
if (href === "/api/settings/provider-models?provider=openai") {
|
||||
return jsonResponse({
|
||||
provider: "openai",
|
||||
@@ -1996,8 +2010,8 @@ describe("App layout", () => {
|
||||
.getAllByRole("button", { name: /OpenAI/ })
|
||||
.some((button) => button.getAttribute("aria-haspopup") === "menu"),
|
||||
).toBe(true);
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
|
||||
fireEvent.click(await screen.findByText("openai/gpt-4o-mini"));
|
||||
await user.click(screen.getByRole("button", { name: "Select model" }));
|
||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
|
||||
@@ -2007,13 +2021,12 @@ describe("App layout", () => {
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
|
||||
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
|
||||
const openModelPicker = () => {
|
||||
const openModelPicker = async () => {
|
||||
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await user.click(modelButtons[modelButtons.length - 1]);
|
||||
};
|
||||
openModelPicker();
|
||||
await screen.findByText("openai/gpt-4o-mini");
|
||||
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
|
||||
await openModelPicker();
|
||||
await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
|
||||
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Model providers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add your own model provider" })).toBeInTheDocument();
|
||||
@@ -2095,12 +2108,13 @@ describe("App layout", () => {
|
||||
expect(screen.queryByText("Unified session")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Default workspace")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" }));
|
||||
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Search timezone"), {
|
||||
fireEvent.click(screen.getByRole("button", { name: "UTC" }));
|
||||
const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
|
||||
expect(timezoneSearch).toBeInTheDocument();
|
||||
fireEvent.change(timezoneSearch, {
|
||||
target: { value: "Shanghai" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: /Asia\/Shanghai/ }));
|
||||
await user.click(screen.getByRole("option", { name: /Asia\/Shanghai/ }));
|
||||
expect(screen.getByRole("button", { name: "Asia/Shanghai" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ComboboxOption,
|
||||
useComboboxNavigation,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
const OPTIONS = ["Alpha", "Beta", "Gamma"];
|
||||
|
||||
function ComboboxHarness({ options = OPTIONS }: { options?: readonly string[] }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [selected, setSelected] = useState("Beta");
|
||||
const navigation = useComboboxNavigation({
|
||||
open,
|
||||
values: options,
|
||||
selectedValue: selected,
|
||||
onSelect: setSelected,
|
||||
onClose: () => setOpen(false),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<input aria-label="Options" {...navigation.inputProps} />
|
||||
<button type="button" onClick={() => setOpen((current) => !current)}>
|
||||
{open ? "Close options" : "Open options"}
|
||||
</button>
|
||||
{open && options.length ? (
|
||||
<div {...navigation.listProps} aria-label="Available options">
|
||||
{options.map((option) => (
|
||||
<ComboboxOption key={option} {...navigation.getOptionProps(option)}>
|
||||
{option}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<output aria-label="Selection">{selected}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("combobox navigation", () => {
|
||||
it("exposes listbox semantics and selects the active option from the keyboard", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
expect(input).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
expect(screen.getByRole("option", { name: "Gamma" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
expect(input).toHaveAttribute(
|
||||
"aria-activedescendant",
|
||||
screen.getByRole("option", { name: "Gamma" }).id,
|
||||
);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma");
|
||||
});
|
||||
|
||||
it("preserves native text editing keys", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
for (const key of ["Home", "End"]) {
|
||||
const event = createEvent.keyDown(input, { key });
|
||||
fireEvent(input, event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("restores the selected option after closing without a selection", () => {
|
||||
render(<ComboboxHarness />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(screen.queryByRole("listbox", { name: "Available options" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open options" }));
|
||||
|
||||
const selectedOption = screen.getByRole("option", { name: "Beta" });
|
||||
expect(input).toHaveAttribute("aria-activedescendant", selectedOption.id);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Beta");
|
||||
});
|
||||
|
||||
it("collapses the combobox when no options are available", () => {
|
||||
render(<ComboboxHarness options={[]} />);
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Options" });
|
||||
expect(input).toHaveAttribute("aria-expanded", "false");
|
||||
expect(input).not.toHaveAttribute("aria-controls");
|
||||
expect(input).not.toHaveAttribute("aria-activedescendant");
|
||||
|
||||
const event = createEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent(input, event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -56,6 +56,8 @@ describe("SessionInfoPopover", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/sessions/websocket%3Achat-1/automations",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
@@ -372,6 +373,10 @@ async function togglePresetEditor(name = "primary") {
|
||||
fireEvent.click(within(row).getAllByRole("button")[0]);
|
||||
}
|
||||
|
||||
async function openPopover(trigger: HTMLElement) {
|
||||
await userEvent.setup().click(trigger);
|
||||
}
|
||||
|
||||
async function chooseProviderToConfigure(label: string) {
|
||||
fireEvent.pointerDown(
|
||||
await screen.findByRole("button", { name: "Add your own model provider" }),
|
||||
@@ -2460,8 +2465,8 @@ describe("SettingsView Apps catalog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
|
||||
target: { value: "Writer" },
|
||||
});
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" }));
|
||||
const modelSearch = await screen.findByRole("textbox", {
|
||||
await openPopover(screen.getByRole("button", { name: "Select model" }));
|
||||
const modelSearch = await screen.findByRole("combobox", {
|
||||
name: "Search or type model ID",
|
||||
});
|
||||
fireEvent.change(modelSearch, {
|
||||
@@ -3275,24 +3280,24 @@ describe("SettingsView Apps catalog", () => {
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" }));
|
||||
await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
|
||||
const modelInput = await screen.findByRole("textbox", { name: "Search or type model ID" });
|
||||
await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
|
||||
const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
|
||||
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } });
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Use “imagen-5-preview”" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "Use “imagen-5-preview”" }));
|
||||
expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
|
||||
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-5-preview" }));
|
||||
const customProviderInput = await screen.findByRole("textbox", {
|
||||
await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
|
||||
const customProviderInput = await screen.findByRole("combobox", {
|
||||
name: "Search or type model ID",
|
||||
});
|
||||
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
|
||||
@@ -3638,7 +3643,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
renderSettingsView({ initialSection: "models" });
|
||||
|
||||
await togglePresetEditor();
|
||||
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i }));
|
||||
await openPopover(await screen.findByRole("button", { name: /Select model/i }));
|
||||
expect(
|
||||
await screen.findByText("Configure this provider before loading models."),
|
||||
).toBeInTheDocument();
|
||||
@@ -3698,7 +3703,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
await togglePresetEditor();
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
|
||||
expect(input.value).toBe("open-codex/gpt-5.5");
|
||||
|
||||
@@ -3776,7 +3781,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
const modelButtons = await screen.findAllByRole("button", {
|
||||
name: /openai-codex\/gpt-5\.5/i,
|
||||
});
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
|
||||
expect(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
|
||||
@@ -3900,7 +3905,7 @@ describe("SettingsView Apps catalog", () => {
|
||||
|
||||
await togglePresetEditor();
|
||||
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i });
|
||||
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]);
|
||||
await openPopover(modelButtons[modelButtons.length - 1]);
|
||||
await screen.findByText("deepseek-reasoner");
|
||||
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadComposer } from "@/components/thread/ThreadComposer";
|
||||
@@ -1007,6 +1008,7 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
|
||||
it("keeps project selection as a compact composer dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
@@ -1030,10 +1032,10 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
const input = screen.getByLabelText("Paste path");
|
||||
fireEvent.change(input, { target: { value: "relative/project" } });
|
||||
@@ -1054,7 +1056,7 @@ describe("ThreadComposer", () => {
|
||||
restrict_to_workspace: false,
|
||||
}));
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
const reopenedInput = await screen.findByLabelText("Paste path");
|
||||
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
|
||||
@@ -1102,7 +1104,7 @@ describe("ThreadComposer", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("menuitem", { name: /Default workspace/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Default workspace/ })).not.toBeInTheDocument();
|
||||
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
project_path: "/Users/test/native-project",
|
||||
project_name: "native-project",
|
||||
@@ -1112,6 +1114,7 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
|
||||
it("uses the web path menu when no native host picker is available", async () => {
|
||||
const user = userEvent.setup();
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
@@ -1131,9 +1134,9 @@ describe("ThreadComposer", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole("button", { name: "Choose project" }));
|
||||
await user.click(screen.getByRole("button", { name: "Choose project" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user