Compare commits

...
17 changed files with 642 additions and 175 deletions
+5
View File
@@ -8,6 +8,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@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-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-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=="], "@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-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-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=="], "@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=="],
+56
View File
@@ -11,6 +11,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"license": "MIT", "license": "MIT",
+1
View File
@@ -15,6 +15,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.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-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
+2 -9
View File
@@ -46,7 +46,6 @@ import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160; const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160; const VISIBLE_SESSIONS_INCREMENT = 160;
const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]"; 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 { interface ChatListProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
@@ -337,7 +336,6 @@ export const ChatList = memo(function ChatList({
> >
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onTogglePin(s.key)} onSelect={() => onTogglePin(s.key)}
className={ACTION_MENU_ITEM_CLASS}
> >
{isPinned ? ( {isPinned ? (
<PinOff className="h-4 w-4 shrink-0" /> <PinOff className="h-4 w-4 shrink-0" />
@@ -348,14 +346,12 @@ export const ChatList = memo(function ChatList({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)} onSelect={() => onRequestRename(s.key, title)}
className={ACTION_MENU_ITEM_CLASS}
> >
<Pencil className="h-4 w-4 shrink-0" /> <Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")} {t("chat.rename")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onToggleArchive(s.key)} onSelect={() => onToggleArchive(s.key)}
className={ACTION_MENU_ITEM_CLASS}
> >
{isArchived ? ( {isArchived ? (
<ArchiveRestore className="h-4 w-4 shrink-0" /> <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")} {isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
tone="destructive"
onSelect={() => { onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0); 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" /> <Trash2 className="h-4 w-4 shrink-0" />
{t("chat.delete")} {t("chat.delete")}
@@ -472,7 +465,7 @@ function ProjectGroupHeader({
portalContainer={actionMenuPortalContainer} portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()} onCloseAutoFocus={(event) => event.preventDefault()}
> >
<DropdownMenuItem onSelect={onRequestRename} className={ACTION_MENU_ITEM_CLASS}> <DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="h-4 w-4 shrink-0" /> <Pencil className="h-4 w-4 shrink-0" />
{t("chat.rename")} {t("chat.rename")}
</DropdownMenuItem> </DropdownMenuItem>
+139 -71
View File
@@ -84,6 +84,10 @@ import {
ChannelSetupPanel, ChannelSetupPanel,
} from "@/components/settings/channels/ChannelSetupPanel"; } from "@/components/settings/channels/ChannelSetupPanel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
ComboboxOption,
useComboboxNavigation,
} from "@/components/ui/combobox";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -100,6 +104,11 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { isLoopbackHost } from "@/lib/network"; import { isLoopbackHost } from "@/lib/network";
import { import {
@@ -2537,7 +2546,7 @@ function SettingsSidebar({
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
sideOffset={6} 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 }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
@@ -2547,7 +2556,7 @@ function SettingsSidebar({
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
onSelect={() => onSelectSection(key)} onSelect={() => onSelectSection(key)}
className={cn( 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", active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
)} )}
> >
@@ -4748,11 +4757,11 @@ function ProvidersSettings({
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={8} 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 <DropdownMenuItem
onSelect={beginCustomProviderCreation} 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} /> <ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
<span className="truncate text-[13px] font-medium"> <span className="truncate text-[13px] font-medium">
@@ -4769,7 +4778,7 @@ function ProvidersSettings({
onToggleProvider(provider.name); 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 <ProviderIcon
provider={provider.name} provider={provider.name}
@@ -7472,15 +7481,19 @@ function CliAppsCatalogRow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}> <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")} {tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}> <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")} {tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}> <DropdownMenuItem
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden /> tone="destructive"
disabled={busy}
onClick={() => onAction("uninstall", app.name)}
>
<Trash2 aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")} {tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -7604,17 +7617,21 @@ function McpAppsCatalogRow({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}> <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")} {tx("settings.mcp.test", "Test")}
</DropdownMenuItem> </DropdownMenuItem>
{toolNames.length ? ( {toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}> <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")} {tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem disabled={busy} onClick={() => onAction("remove", preset.name)}> <DropdownMenuItem
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden /> tone="destructive"
disabled={busy}
onClick={() => onAction("remove", preset.name)}
>
<Trash2 aria-hidden />
{tx("settings.mcp.remove", "Remove")} {tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -8821,13 +8838,35 @@ function TimezonePicker({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const options = useMemo(() => timezoneOptions(value), [value]); const options = useMemo(() => timezoneOptions(value), [value]);
const filteredOptions = useMemo(() => filterTimezoneOptions(options, query), [options, query]); 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 ( return (
<DropdownMenu onOpenChange={(open) => !open && setQuery("")}> <Popover
<DropdownMenuTrigger asChild> open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -8839,8 +8878,8 @@ function TimezonePicker({
<span className="truncate">{value || tx("settings.timezone.select", "Select timezone")}</span> <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 /> <ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
className="w-[340px] max-w-[calc(100vw-2rem)]" className="w-[340px] max-w-[calc(100vw-2rem)]"
> >
@@ -8851,27 +8890,29 @@ function TimezonePicker({
autoFocus autoFocus
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()} {...navigation.inputProps}
placeholder={tx("settings.timezone.search", "Search timezone")} 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" className="h-7 border-0 bg-transparent px-0 text-[13px] shadow-none focus-visible:ring-0"
/> />
</div> </div>
</div> </div>
{filteredOptions.length ? (
<div <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" className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
data-testid="timezone-picker-list" data-testid="timezone-picker-list"
> >
{filteredOptions.length ? ( {filteredOptions.map((option) => {
filteredOptions.map((option) => {
const selected = option.name === value; const selected = option.name === value;
return ( return (
<DropdownMenuItem <ComboboxOption
key={option.name} key={option.name}
onSelect={() => onChange(option.name)} {...navigation.getOptionProps(option.name)}
className={cn( className={cn(
"flex h-9 cursor-default items-center justify-between gap-3 rounded-[12px] px-2.5 text-[13px]", "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 && "text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
<span className="min-w-0 truncate font-medium text-foreground">{option.name}</span> <span className="min-w-0 truncate font-medium text-foreground">{option.name}</span>
@@ -8881,17 +8922,21 @@ function TimezonePicker({
</span> </span>
{selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null} {selected ? <Check className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
</span> </span>
</DropdownMenuItem> </ComboboxOption>
); );
}) })}
</div>
) : ( ) : (
<div className="px-3 py-5 text-center text-[12px] text-muted-foreground"> <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.")} {tx("settings.timezone.empty", "No matching timezones.")}
</div> </div>
)} )}
</div> </PopoverContent>
</DropdownMenuContent> </Popover>
</DropdownMenu>
); );
} }
@@ -8947,8 +8992,7 @@ function ProviderPicker({
key={provider.name} key={provider.name}
onSelect={() => onChange(provider.name)} onSelect={() => onChange(provider.name)}
className={cn( className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2.5 py-2 text-[13px]", "flex cursor-default items-center justify-between gap-2 text-[13px]",
"focus:bg-muted/85 focus:text-foreground",
selected && "bg-muted/80 text-foreground focus:bg-muted", selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
@@ -9021,16 +9065,22 @@ function ModelIdPicker({
!hasStaticModels && !hasStaticModels &&
hasConcreteProvider && providerConfigured && !providerUsesManualModelIds; hasConcreteProvider && providerConfigured && !providerUsesManualModelIds;
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const providerModels: ProviderModelsPayload["models"] = hasStaticModels const providerModels: ProviderModelsPayload["models"] = useMemo(
() => hasStaticModels
? (models?.map((id) => ({ id })) ?? []) ? (models?.map((id) => ({ id })) ?? [])
: (payload?.models ?? []); : (payload?.models ?? []),
const visibleModels = providerModels [hasStaticModels, models, payload?.models],
);
const visibleModels = useMemo(
() => providerModels
.filter((model) => { .filter((model) => {
if (!normalizedQuery) return true; if (!normalizedQuery) return true;
return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""] return [model.id, model.label ?? "", model.description ?? "", model.owned_by ?? ""]
.some((field) => field.toLowerCase().includes(normalizedQuery)); .some((field) => field.toLowerCase().includes(normalizedQuery));
}) })
.slice(0, 80); .slice(0, 80),
[normalizedQuery, providerModels],
);
const isCatalog = payload?.catalog_kind === "catalog"; const isCatalog = payload?.catalog_kind === "catalog";
const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider); const defersModelList = DEFERRED_MODEL_LIST_PROVIDERS.has(effectiveProvider);
const hasDeferredSearchQuery = const hasDeferredSearchQuery =
@@ -9046,6 +9096,9 @@ function ModelIdPicker({
const customCandidate = query.trim(); const customCandidate = query.trim();
const allowCustomModel = !providerRequiresConfiguration; const allowCustomModel = !providerRequiresConfiguration;
const exactQueryMatch = providerModels.some((model) => model.id === customCandidate); const exactQueryMatch = providerModels.some((model) => model.id === customCandidate);
const showCustomModel = Boolean(
allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value,
);
const providerModelCount = payload?.model_count ?? providerModels.length; const providerModelCount = payload?.model_count ?? providerModels.length;
const modelUnconfigured = !value.trim() || !providerConfigured; const modelUnconfigured = !value.trim() || !providerConfigured;
@@ -9084,18 +9137,31 @@ function ModelIdPicker({
onChange(model); onChange(model);
setOpen(false); 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 = ( const renderModelRow = (
model: ProviderModelsPayload["models"][number], model: ProviderModelsPayload["models"][number],
options: { selected?: boolean } = {}, options: { selected?: boolean } = {},
) => ( ) => (
<DropdownMenuItem <ComboboxOption
key={model.id} key={model.id}
onSelect={() => selectModel(model.id)} {...navigation.getOptionProps(model.id)}
className={cn( className={cn(
"flex cursor-default items-center justify-between gap-2 rounded-[12px] px-2 py-1.5 text-[12px]", "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 && "text-foreground",
options.selected && "bg-muted/80 text-foreground focus:bg-muted",
)} )}
> >
<span className="flex min-w-0 items-center gap-2"> <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} {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} {options.selected ? <Check className="h-3.5 w-3.5 text-foreground" aria-hidden /> : null}
</span> </span>
</DropdownMenuItem> </ComboboxOption>
); );
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -9152,8 +9218,8 @@ function ModelIdPicker({
</span> </span>
<ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden /> <ChevronDown className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5" className="w-[360px] max-w-[calc(100vw-2rem)] p-1.5"
> >
@@ -9166,13 +9232,7 @@ function ModelIdPicker({
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => { {...navigation.inputProps}
event.stopPropagation();
if (event.key === "Enter" && allowCustomModel && customCandidate) {
event.preventDefault();
selectModel(customCandidate);
}
}}
placeholder={ placeholder={
searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID") searchPlaceholder || tx("settings.models.searchModels", "Search or type model ID")
} }
@@ -9228,24 +9288,25 @@ function ModelIdPicker({
</div> </div>
) : null} ) : null}
{showModels && visibleModels.length ? ( {navigationValues.length ? (
<div className="max-h-[16rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"> <div
{visibleModels.map((model) => {...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 }), renderModelRow(model, { selected: model.id === value }),
)} )
</div> : null}
) : showModels ? ( {showCustomModel ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
{allowCustomModel && customCandidate && !exactQueryMatch && customCandidate !== value ? (
<> <>
{showModels ? <DropdownMenuSeparator /> : null} {showModels && visibleModels.length ? (
<DropdownMenuItem <div role="separator" className="-mx-1.5 my-1.5 h-px bg-border/50" />
onSelect={() => selectModel(customCandidate)} ) : null}
className="flex cursor-default items-center gap-2 rounded-[12px] px-2 py-1.5 text-[12px] focus:bg-muted/85" <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"> <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 /> <Pencil className="h-3 w-3" aria-hidden />
@@ -9254,11 +9315,18 @@ function ModelIdPicker({
{tx("settings.models.useCustomModel", "Use")}{" "} {tx("settings.models.useCustomModel", "Use")}{" "}
<span className="font-medium text-foreground">{customCandidate}</span> <span className="font-medium text-foreground">{customCandidate}</span>
</span> </span>
</DropdownMenuItem> </ComboboxOption>
</> </>
) : null} ) : null}
</DropdownMenuContent> </div>
</DropdownMenu> ) : showModels ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">
{tx("settings.models.noModelResults", "No matching models.")}
</div>
) : null}
</PopoverContent>
</Popover>
); );
} }
@@ -10,10 +10,10 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, Popover,
DropdownMenuContent, PopoverContent,
DropdownMenuTrigger, PopoverTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/popover";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs"; import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n"; import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format"; import { fmtDateTime } from "@/lib/format";
@@ -63,8 +63,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
); );
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -76,11 +76,11 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
> >
<ListTodo className="h-4 w-4 stroke-[1.75]" /> <ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button> </Button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="end" align="end"
sideOffset={8} 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="space-y-3 px-4 py-3.5">
<div className="min-w-0"> <div className="min-w-0">
@@ -108,8 +108,8 @@ export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopo
{automationContent} {automationContent}
</div> </div>
</DropdownMenuContent> </PopoverContent>
</DropdownMenu> </Popover>
); );
} }
+12 -8
View File
@@ -50,6 +50,10 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
floatingItemClassName,
floatingSurfaceVisualClassName,
} from "@/components/ui/floating-surface";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -2688,10 +2692,9 @@ function CliAppMentionPalette({
aria-label={t("thread.composer.mentions.ariaLabel")} aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }} style={{ maxHeight: layout.maxHeight }}
className={cn( 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", 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]", isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)} )}
> >
@@ -2728,7 +2731,8 @@ function CliAppMentionPalette({
onChoose(candidate); onChoose(candidate);
}} }}
className={cn( 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 selected
? "bg-foreground/[0.055] text-foreground" ? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]", : "text-foreground/90 hover:bg-foreground/[0.04]",
@@ -2836,10 +2840,9 @@ function SlashCommandPalette({
aria-label={t("thread.composer.slash.ariaLabel")} aria-label={t("thread.composer.slash.ariaLabel")}
style={{ maxHeight: layout.maxHeight }} style={{ maxHeight: layout.maxHeight }}
className={cn( 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", 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]", isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)} )}
> >
@@ -2868,7 +2871,8 @@ function SlashCommandPalette({
onChoose(command); onChoose(command);
}} }}
className={cn( 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 selected
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]" ? "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]", : "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
@@ -9,7 +9,16 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
floatingItemClassName,
floatingItemFocusClassName,
} from "@/components/ui/floating-surface";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { import type {
WorkspaceAccessMode, WorkspaceAccessMode,
WorkspaceScopePayload, WorkspaceScopePayload,
@@ -134,8 +143,8 @@ export function WorkspaceProjectPicker({
return ( 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"> <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}> <Popover open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
disabled={disabled} disabled={disabled}
@@ -151,16 +160,21 @@ export function WorkspaceProjectPicker({
<span className="truncate">{projectLabel}</span> <span className="truncate">{projectLabel}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button> </button>
</DropdownMenuTrigger> </PopoverTrigger>
<DropdownMenuContent <PopoverContent
align="start" align="start"
side="bottom" side="bottom"
sideOffset={8} sideOffset={8}
className="w-[min(25rem,calc(100vw-2rem))] rounded-[22px]" className="w-[min(25rem,calc(100vw-2rem))]"
> >
<DropdownMenuItem <button
onSelect={() => applyProjectPath(defaultScope.project_path, defaultScope.project_name)} type="button"
className="flex min-h-[48px] cursor-default gap-3 rounded-[16px] px-3 py-2.5 focus:bg-muted/55" 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"> <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" /> <Folder className="h-4 w-4" />
@@ -174,14 +188,9 @@ export function WorkspaceProjectPicker({
</span> </span>
</span> </span>
{!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null} {!currentProjectScope ? <Check className="h-4 w-4 text-foreground/80" /> : null}
</DropdownMenuItem> </button>
<div className="my-1 h-px bg-border/45" /> <div className="my-1 h-px bg-border/45" />
<div <div className="space-y-1.5 px-1.5 py-1.5">
className="space-y-1.5 px-1.5 py-1.5"
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<form <form
className="flex items-center gap-2" className="flex items-center gap-2"
onSubmit={(event) => { onSubmit={(event) => {
@@ -217,8 +226,8 @@ export function WorkspaceProjectPicker({
</p> </p>
) : null} ) : null}
</div> </div>
</DropdownMenuContent> </PopoverContent>
</DropdownMenu> </Popover>
</div> </div>
); );
} }
@@ -323,7 +332,7 @@ function AccessMenuItem({
disabled={disabled} disabled={disabled}
onSelect={onSelect} onSelect={onSelect}
className={cn( 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", warning && "text-orange-600 focus:text-orange-600 dark:text-orange-300 dark:focus:text-orange-300",
)} )}
> >
+133
View File
@@ -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 };
+15 -8
View File
@@ -2,6 +2,12 @@ import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react"; import { Check, ChevronRight, Circle } from "lucide-react";
import {
floatingItemClassName,
floatingItemFocusClassName,
floatingSurfaceClassName,
floatingSurfaceMotionClassName,
} from "@/components/ui/floating-surface";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -11,11 +17,8 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 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 = 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< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
@@ -46,7 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
menuContentClassName, floatingSurfaceClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
className, className,
)} )}
{...props} {...props}
@@ -68,8 +72,9 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
menuContentClassName, floatingSurfaceClassName,
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", floatingSurfaceMotionClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
className, className,
)} )}
{...props} {...props}
@@ -82,13 +87,15 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean; inset?: boolean;
tone?: "default" | "destructive";
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, tone = "default", ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
menuItemClassName, menuItemClassName,
inset && "pl-8", inset && "pl-8",
tone === "destructive" && "text-destructive focus:text-destructive",
className, className,
)} )}
{...props} {...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]";
+42
View File
@@ -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,
};
+25 -11
View File
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n"; import i18n from "@/i18n";
@@ -1715,6 +1716,7 @@ describe("App layout", () => {
}); });
it("opens the settings view from the sidebar footer", async () => { it("opens the settings view from the sidebar footer", async () => {
const user = userEvent.setup();
mockSessions = [ mockSessions = [
{ {
key: "websocket:chat-a", key: "websocket:chat-a",
@@ -1729,6 +1731,18 @@ describe("App layout", () => {
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
const href = String(input); 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") { if (href === "/api/settings/provider-models?provider=openai") {
return jsonResponse({ return jsonResponse({
provider: "openai", provider: "openai",
@@ -1996,8 +2010,8 @@ describe("App layout", () => {
.getAllByRole("button", { name: /OpenAI/ }) .getAllByRole("button", { name: /OpenAI/ })
.some((button) => button.getAttribute("aria-haspopup") === "menu"), .some((button) => button.getAttribute("aria-haspopup") === "menu"),
).toBe(true); ).toBe(true);
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" })); await user.click(screen.getByRole("button", { name: "Select model" }));
fireEvent.click(await screen.findByText("openai/gpt-4o-mini")); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" })); fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByText("Up to date.")).not.toBeInTheDocument(); expect(screen.queryByText("Up to date.")).not.toBeInTheDocument();
@@ -2007,13 +2021,12 @@ describe("App layout", () => {
fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ })); fireEvent.pointerDown(screen.getByRole("button", { name: /Auto/ }));
expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0); expect(screen.getAllByTestId("provider-picker-logo-openai").length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ })); fireEvent.click(screen.getByRole("menuitem", { name: /Auto/ }));
const openModelPicker = () => { const openModelPicker = async () => {
const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ }); const modelButtons = screen.getAllByRole("button", { name: /openai\/gpt-4o/ });
fireEvent.pointerDown(modelButtons[modelButtons.length - 1]); await user.click(modelButtons[modelButtons.length - 1]);
}; };
openModelPicker(); await openModelPicker();
await screen.findByText("openai/gpt-4o-mini"); await user.click(await screen.findByRole("option", { name: /openai\/gpt-4o-mini/ }));
fireEvent.click(screen.getAllByText("openai/gpt-4o-mini")[0]);
expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument(); expect(screen.queryByText("Unsaved changes.")).not.toBeInTheDocument();
expect(screen.getByText("Model providers")).toBeInTheDocument(); expect(screen.getByText("Model providers")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add your own model provider" })).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.queryByText("Unified session")).not.toBeInTheDocument();
expect(screen.getByText("Default workspace")).toBeInTheDocument(); expect(screen.getByText("Default workspace")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.pointerDown(screen.getByRole("button", { name: "UTC" })); fireEvent.click(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByPlaceholderText("Search timezone")).toBeInTheDocument(); const timezoneSearch = await screen.findByPlaceholderText("Search timezone");
fireEvent.change(screen.getByPlaceholderText("Search timezone"), { expect(timezoneSearch).toBeInTheDocument();
fireEvent.change(timezoneSearch, {
target: { value: "Shanghai" }, 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: "Asia/Shanghai" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
}); });
+111
View File
@@ -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" })); await user.click(screen.getByRole("button", { name: "Session details" }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/automations", "/api/sessions/websocket%3Achat-1/automations",
+18 -13
View File
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsView } from "@/components/settings/SettingsView"; import { SettingsView } from "@/components/settings/SettingsView";
@@ -372,6 +373,10 @@ async function togglePresetEditor(name = "primary") {
fireEvent.click(within(row).getAllByRole("button")[0]); fireEvent.click(within(row).getAllByRole("button")[0]);
} }
async function openPopover(trigger: HTMLElement) {
await userEvent.setup().click(trigger);
}
async function chooseProviderToConfigure(label: string) { async function chooseProviderToConfigure(label: string) {
fireEvent.pointerDown( fireEvent.pointerDown(
await screen.findByRole("button", { name: "Add your own model provider" }), await screen.findByRole("button", { name: "Add your own model provider" }),
@@ -2460,8 +2465,8 @@ describe("SettingsView Apps catalog", () => {
fireEvent.change(screen.getByPlaceholderText("Fast writing"), { fireEvent.change(screen.getByPlaceholderText("Fast writing"), {
target: { value: "Writer" }, target: { value: "Writer" },
}); });
fireEvent.pointerDown(screen.getByRole("button", { name: "Select model" })); await openPopover(screen.getByRole("button", { name: "Select model" }));
const modelSearch = await screen.findByRole("textbox", { const modelSearch = await screen.findByRole("combobox", {
name: "Search or type model ID", name: "Search or type model ID",
}); });
fireEvent.change(modelSearch, { fireEvent.change(modelSearch, {
@@ -3275,24 +3280,24 @@ describe("SettingsView Apps catalog", () => {
fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Gemini" }));
expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument(); expect(await screen.findByRole("button", { name: "gemini-2.5-flash-image" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "gemini-2.5-flash-image" })); await openPopover(screen.getByRole("button", { name: "gemini-2.5-flash-image" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "imagen-4.0-generate-001" })); fireEvent.click(await screen.findByRole("option", { name: "imagen-4.0-generate-001" }));
await waitFor(() => await waitFor(() =>
expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(), expect(screen.getByRole("button", { name: "imagen-4.0-generate-001" })).toBeInTheDocument(),
); );
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-4.0-generate-001" })); await openPopover(screen.getByRole("button", { name: "imagen-4.0-generate-001" }));
const modelInput = await screen.findByRole("textbox", { name: "Search or type model ID" }); const modelInput = await screen.findByRole("combobox", { name: "Search or type model ID" });
fireEvent.change(modelInput, { target: { value: "imagen-5-preview" } }); 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(); expect(await screen.findByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" })); fireEvent.pointerDown(screen.getByRole("button", { name: "Gemini" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" })); fireEvent.click(await screen.findByRole("menuitem", { name: "Custom" }));
expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "imagen-5-preview" })).toBeInTheDocument();
fireEvent.pointerDown(screen.getByRole("button", { name: "imagen-5-preview" })); await openPopover(screen.getByRole("button", { name: "imagen-5-preview" }));
const customProviderInput = await screen.findByRole("textbox", { const customProviderInput = await screen.findByRole("combobox", {
name: "Search or type model ID", name: "Search or type model ID",
}); });
fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } }); fireEvent.change(customProviderInput, { target: { value: "private/image-v2" } });
@@ -3638,7 +3643,7 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "models" }); renderSettingsView({ initialSection: "models" });
await togglePresetEditor(); await togglePresetEditor();
fireEvent.pointerDown(await screen.findByRole("button", { name: /Select model/i })); await openPopover(await screen.findByRole("button", { name: /Select model/i }));
expect( expect(
await screen.findByText("Configure this provider before loading models."), await screen.findByText("Configure this provider before loading models."),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -3698,7 +3703,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor(); await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /open-codex\/gpt-5\.5/i }); 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; const input = (await screen.findByPlaceholderText("Search or type model ID")) as HTMLInputElement;
expect(input.value).toBe("open-codex/gpt-5.5"); expect(input.value).toBe("open-codex/gpt-5.5");
@@ -3776,7 +3781,7 @@ describe("SettingsView Apps catalog", () => {
const modelButtons = await screen.findAllByRole("button", { const modelButtons = await screen.findAllByRole("button", {
name: /openai-codex\/gpt-5\.5/i, 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(await screen.findByText("GPT-5.6-Sol")).toBeInTheDocument();
expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument(); expect(screen.getByText(/Latest frontier agentic coding model\./)).toBeInTheDocument();
@@ -3900,7 +3905,7 @@ describe("SettingsView Apps catalog", () => {
await togglePresetEditor(); await togglePresetEditor();
const modelButtons = await screen.findAllByRole("button", { name: /deepseek-chat/i }); 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"); await screen.findByText("deepseek-reasoner");
fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]); fireEvent.click(screen.getAllByText("deepseek-reasoner")[0]);
fireEvent.click(screen.getByRole("button", { name: /Advanced options/ })); fireEvent.click(screen.getByRole("button", { name: /Advanced options/ }));
+10 -7
View File
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; 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 { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
@@ -1007,6 +1008,7 @@ describe("ThreadComposer", () => {
}); });
it("keeps project selection as a compact composer dropdown", async () => { it("keeps project selection as a compact composer dropdown", async () => {
const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn(); const onWorkspaceScopeChange = vi.fn();
const defaultScope = { const defaultScope = {
project_path: "/Users/test/.nanobot/workspace", 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(await screen.findByRole("button", { name: /Default workspace/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
const input = screen.getByLabelText("Paste path"); const input = screen.getByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } }); fireEvent.change(input, { target: { value: "relative/project" } });
@@ -1054,7 +1056,7 @@ describe("ThreadComposer", () => {
restrict_to_workspace: false, 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"); const reopenedInput = await screen.findByLabelText("Paste path");
fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } }); fireEvent.change(reopenedInput, { target: { value: "~/Pictures/Photos" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" })); fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
@@ -1102,7 +1104,7 @@ describe("ThreadComposer", () => {
fireEvent.click(screen.getByRole("button", { name: "Choose project" })); fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled()); 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({ expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/native-project", project_path: "/Users/test/native-project",
project_name: "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 () => { it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup();
const defaultScope = { const defaultScope = {
project_path: "/Users/test/.nanobot/workspace", project_path: "/Users/test/.nanobot/workspace",
project_name: "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(); expect(screen.getByLabelText("Paste path")).toBeInTheDocument();
}); });