feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ export { BadgeListCell } from './core/badge-list-cell'
export { TruncatedCell } from './core/truncated-cell'
export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableMobileFilterPanel } from './toolbar/mobile-filter-panel'
export { DataTableBulkActions } from './toolbar/bulk-actions'
export {
StaticDataTable,
@@ -0,0 +1,161 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
getCoreRowModel,
useReactTable,
type ColumnFiltersState,
} from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { afterEach, expect, it, vi } from 'vitest'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DataTableToolbar } from '../toolbar'
const rows = [{ name: 'Channel', model: 'model', status: 'enabled' }]
const filters = [
{
columnId: 'status',
title: 'Status',
options: [{ value: 'enabled', label: 'Enabled' }],
},
]
function Fixture(props: { collapsibleOnMobile?: boolean }) {
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([
{ id: 'status', value: ['enabled'] },
])
const table = useReactTable({
data: rows,
columns: [
{ accessorKey: 'name' },
{ accessorKey: 'model' },
{ accessorKey: 'status' },
],
getCoreRowModel: getCoreRowModel(),
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
})
return (
<DataTableToolbar
table={table}
collapsibleOnMobile={props.collapsibleOnMobile}
searchPlaceholder='Search channels'
searchDebounceMs={50}
filters={filters}
additionalSearch={
<Input
aria-label='Model'
value={String(table.getColumn('model')?.getFilterValue() ?? '')}
onChange={(event) =>
table.getColumn('model')?.setFilterValue(event.target.value)
}
/>
}
preActions={<Button>Refresh</Button>}
viewToggle={<Button>Card view</Button>}
/>
)
}
function setMobileViewport(mobile: boolean) {
const original = window.matchMedia
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
...original(query),
matches: mobile && query === '(max-width: 640px)',
}))
}
afterEach(() => {
vi.restoreAllMocks()
})
it('hides the mobile filter group while keeping reset and quick actions accessible', async () => {
setMobileViewport(true)
const user = userEvent.setup()
render(<Fixture collapsibleOnMobile />)
const collapse = screen.getByRole('button', { name: 'Collapse' })
expect(collapse).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
collapse.focus()
await user.keyboard('{Enter}')
expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute(
'aria-expanded',
'false'
)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^Status/ })
).not.toBeInTheDocument()
for (const name of ['Reset', 'Refresh', 'Card view', 'View']) {
expect(screen.getByRole('button', { name })).toBeVisible()
expect(screen.getByRole('button', { name })).toBeEnabled()
}
})
it('retains mobile search and filter values after expanding and resets them while collapsed', async () => {
setMobileViewport(true)
const user = userEvent.setup()
render(<Fixture collapsibleOnMobile />)
await user.type(screen.getByPlaceholderText('Search channels'), 'production')
await user.type(screen.getByRole('textbox', { name: 'Model' }), 'gpt')
await user.click(screen.getByRole('button', { name: 'Collapse' }))
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByPlaceholderText('Search channels')).toHaveValue(
'production'
)
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('gpt')
expect(screen.getByRole('button', { name: /^Status/ })).toHaveTextContent(
'Enabled'
)
await user.click(screen.getByRole('button', { name: 'Collapse' }))
await user.click(screen.getByRole('button', { name: 'Reset' }))
await user.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByPlaceholderText('Search channels')).toHaveValue('')
expect(screen.getByRole('textbox', { name: 'Model' })).toHaveValue('')
expect(screen.getByRole('button', { name: 'Status' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'Reset' })
).not.toBeInTheDocument()
})
it.each([
{ mobile: false, collapsibleOnMobile: true, condition: 'on desktop' },
{ mobile: true, collapsibleOnMobile: undefined, condition: 'without opt-in' },
])('keeps filters visible without a panel toggle $condition', (testCase) => {
setMobileViewport(testCase.mobile)
render(<Fixture collapsibleOnMobile={testCase.collapsibleOnMobile} />)
expect(screen.getByPlaceholderText('Search channels')).toBeVisible()
expect(screen.getByRole('textbox', { name: 'Model' })).toBeVisible()
expect(screen.getByRole('button', { name: /^Status/ })).toBeVisible()
expect(
screen.queryByRole('button', { name: /^(Collapse|Expand)$/ })
).not.toBeInTheDocument()
})
@@ -0,0 +1,98 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronDown } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { cn } from '@/lib/utils'
type DataTableMobileFilterPanelProps = {
children: ReactNode
actions: ReactNode
compact?: boolean
className?: string
}
export function DataTableMobileFilterPanel(
props: DataTableMobileFilterPanelProps
) {
const { t } = useTranslation()
const [open, setOpen] = useState(true)
return (
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn(
'bg-card/50 flex min-w-0 flex-col rounded-lg border p-2.5',
open && (props.compact ? 'gap-2.5' : 'gap-2'),
props.className
)}
>
<CollapsibleContent>{props.children}</CollapsibleContent>
<div
className={cn(
props.compact
? 'grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-2'
: 'flex items-center justify-end gap-1.5'
)}
>
<CollapsibleTrigger
aria-label={open ? t('Collapse') : t('Expand')}
render={
<Button
variant='ghost'
size='icon'
className={cn(
'text-muted-foreground hover:text-foreground',
props.compact ? 'size-9' : 'mr-auto size-7'
)}
/>
}
>
<ChevronDown
aria-hidden='true'
className={cn(
'transition-transform duration-200',
props.compact ? 'size-4' : 'size-3.5',
open && 'rotate-180'
)}
/>
</CollapsibleTrigger>
<div
role='group'
aria-label={t('Actions')}
className={cn(
'flex items-center justify-end gap-1.5',
props.compact &&
'min-w-0 flex-wrap [&>button]:h-auto [&>button]:min-h-9 [&>button]:max-w-full [&>button]:[overflow-wrap:anywhere] [&>button]:whitespace-normal'
)}
>
{props.actions}
</div>
</div>
</Collapsible>
)
}
@@ -24,10 +24,11 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks'
import { useDebounce, useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils'
import { DataTableFacetedFilter } from './faceted-filter'
import { DataTableMobileFilterPanel } from './mobile-filter-panel'
import { DataTableViewOptions } from './view-options'
type FilterDef = {
@@ -78,6 +79,10 @@ export type DataTableToolbarProps<TData> = {
* search input and filter chips.
*/
additionalSearch?: ReactNode
/**
* Collapse the complete filter panel on mobile while keeping actions visible.
*/
collapsibleOnMobile?: boolean
/**
* Whether non-table filters (e.g. `additionalSearch` or `expandable`
* inputs) are currently active. Controls Reset button visibility
@@ -155,6 +160,7 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [isSearchComposing, setIsSearchComposing] = useState(false)
const isMobile = useMediaQuery('(max-width: 640px)')
const filters = props.filters ?? []
const hasExpandable = props.expandable != null
@@ -341,6 +347,33 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const hasLeftActions = props.leftActions != null
if (isMobile && props.collapsibleOnMobile) {
return (
<DataTableMobileFilterPanel
compact
className={props.className}
actions={
<>
{props.leftActions}
{props.preActions}
{resetButton}
{searchButton}
{viewToggleNode}
{viewOptionsNode}
</>
}
>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
{props.customSearch !== undefined ? props.customSearch : searchInput}
{props.additionalSearch}
{filterChips}
{expanded && hasExpandable && props.expandable}
{expandToggle}
</div>
</DataTableMobileFilterPanel>
)
}
if (hasLeftActions) {
return (
<div className={cn('flex flex-col gap-2', props.className)}>
+477
View File
@@ -0,0 +1,477 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ChevronsUpDown, GripHorizontal, X } from 'lucide-react'
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export type FloatingWindowPosition = {
x: number
y: number
}
type FloatingWindowSize = {
width: number
/** Unset until the user resizes vertically; content decides until then. */
height?: number
}
type FloatingWindowProps = {
title: ReactNode
children: ReactNode
onClose: () => void
/** Viewport coordinates used until the user moves the window. */
defaultPosition: FloatingWindowPosition
defaultWidth: number
defaultHeight?: number
minWidth?: number
minHeight?: number
/** Small element rendered beside the title, e.g. a count badge. */
badge?: ReactNode
footer?: ReactNode
/**
* Persists position, size and collapsed state in `localStorage` under this
* key so the window reopens where and how the user left it.
*/
storageKey?: string
className?: string
}
type FloatingWindowState = {
position: FloatingWindowPosition
size: FloatingWindowSize
collapsed: boolean
}
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
const RESIZE_DIRECTIONS: ResizeDirection[] = [
'n',
's',
'e',
'w',
'ne',
'nw',
'se',
'sw',
]
const RESIZE_HANDLE_CLASS: Record<ResizeDirection, string> = {
n: 'top-0 left-2 right-2 h-1.5 cursor-ns-resize',
s: 'bottom-0 left-2 right-2 h-1.5 cursor-ns-resize',
e: 'top-2 bottom-2 right-0 w-1.5 cursor-ew-resize',
w: 'top-2 bottom-2 left-0 w-1.5 cursor-ew-resize',
ne: 'top-0 right-0 size-3 cursor-nesw-resize',
nw: 'top-0 left-0 size-3 cursor-nwse-resize',
se: 'bottom-0 right-0 size-3 cursor-nwse-resize',
sw: 'bottom-0 left-0 size-3 cursor-nesw-resize',
}
const VIEWPORT_MARGIN = 8
/** Keeps at least the header reachable when the window is dragged downward. */
const HEADER_HEIGHT = 48
const DEFAULT_MIN_WIDTH = 280
const DEFAULT_MIN_HEIGHT = 200
function storageKeyFor(key: string) {
return `floating-window:${key}`
}
function readStoredState(key?: string): FloatingWindowState | null {
if (!key) return null
try {
const raw = window.localStorage.getItem(storageKeyFor(key))
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object') return null
const stored = parsed as Record<string, unknown>
if (typeof stored.x !== 'number' || typeof stored.y !== 'number') {
return null
}
return {
position: { x: stored.x, y: stored.y },
size: {
width: typeof stored.width === 'number' ? stored.width : Number.NaN,
height: typeof stored.height === 'number' ? stored.height : undefined,
},
collapsed: stored.collapsed === true,
}
} catch {
return null
}
}
function clampPosition(
position: FloatingWindowPosition,
width: number
): FloatingWindowPosition {
const maxX = Math.max(
VIEWPORT_MARGIN,
window.innerWidth - width - VIEWPORT_MARGIN
)
const maxY = Math.max(
VIEWPORT_MARGIN,
window.innerHeight - HEADER_HEIGHT - VIEWPORT_MARGIN
)
return {
x: Math.min(Math.max(VIEWPORT_MARGIN, position.x), maxX),
y: Math.min(Math.max(VIEWPORT_MARGIN, position.y), maxY),
}
}
function clampSize(
size: FloatingWindowSize,
limits: { minWidth: number; minHeight: number }
): FloatingWindowSize {
const maxWidth = Math.max(
limits.minWidth,
window.innerWidth - VIEWPORT_MARGIN * 2
)
const maxHeight = Math.max(
limits.minHeight,
window.innerHeight - VIEWPORT_MARGIN * 2
)
return {
width: Math.min(Math.max(limits.minWidth, size.width), maxWidth),
height:
size.height === undefined
? undefined
: Math.min(Math.max(limits.minHeight, size.height), maxHeight),
}
}
/**
* Non-modal window that floats above the page: dragged by its header, resized
* from any edge or corner, collapsible to a pill. It renders where it is
* mounted (no portal) with `position: fixed`, so a parent modal keeps
* treating it as its own content.
*/
export function FloatingWindow(props: FloatingWindowProps) {
const { t } = useTranslation()
const id = useId()
const limits = useMemo(
() => ({
minWidth: props.minWidth ?? DEFAULT_MIN_WIDTH,
minHeight: props.minHeight ?? DEFAULT_MIN_HEIGHT,
}),
[props.minWidth, props.minHeight]
)
const [state, setState] = useState<FloatingWindowState>(() => {
const stored = readStoredState(props.storageKey)
const size = clampSize(
{
width:
stored && !Number.isNaN(stored.size.width)
? stored.size.width
: props.defaultWidth,
height: stored ? stored.size.height : props.defaultHeight,
},
limits
)
return {
position: clampPosition(
stored?.position ?? props.defaultPosition,
size.width
),
size,
collapsed: stored?.collapsed ?? false,
}
})
const windowRef = useRef<HTMLElement>(null)
const dragRef = useRef<{
pointerId: number
offsetX: number
offsetY: number
} | null>(null)
const resizeRef = useRef<{
pointerId: number
direction: ResizeDirection
startX: number
startY: number
position: FloatingWindowPosition
size: { width: number; height: number }
} | null>(null)
useEffect(() => {
if (!props.storageKey) return
try {
window.localStorage.setItem(
storageKeyFor(props.storageKey),
JSON.stringify({
x: state.position.x,
y: state.position.y,
width: state.size.width,
height: state.size.height,
collapsed: state.collapsed,
})
)
} catch {
// Storage may be unavailable; the window still works for this session.
}
}, [props.storageKey, state])
useEffect(() => {
const keepInViewport = () =>
setState((previous) => {
const size = clampSize(previous.size, limits)
return {
...previous,
size,
position: clampPosition(previous.position, size.width),
}
})
window.addEventListener('resize', keepInViewport)
return () => window.removeEventListener('resize', keepInViewport)
}, [limits])
const capturePointer = (
event: ReactPointerEvent<HTMLElement>,
capture: boolean
) => {
const method = capture ? 'setPointerCapture' : 'releasePointerCapture'
if (typeof event.currentTarget[method] === 'function') {
event.currentTarget[method](event.pointerId)
}
}
const handleDragStart = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return
if (
event.target instanceof Element &&
event.target.closest('button, a, input, select, textarea')
) {
return
}
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - state.position.x,
offsetY: event.clientY - state.position.y,
}
capturePointer(event, true)
}
const handleDragMove = (event: ReactPointerEvent<HTMLElement>) => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
const next = clampPosition(
{ x: event.clientX - drag.offsetX, y: event.clientY - drag.offsetY },
state.size.width
)
setState((previous) => ({ ...previous, position: next }))
}
const handleDragEnd = (event: ReactPointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
dragRef.current = null
capturePointer(event, false)
}
const handleResizeStart = (
event: ReactPointerEvent<HTMLElement>,
direction: ResizeDirection
) => {
if (event.button !== 0) return
event.preventDefault()
// Until the user has set a height the window is content-sized, so the
// rendered height becomes the starting point of a vertical resize.
const renderedHeight = windowRef.current?.getBoundingClientRect().height
resizeRef.current = {
pointerId: event.pointerId,
direction,
startX: event.clientX,
startY: event.clientY,
position: state.position,
size: {
width: state.size.width,
height:
state.size.height ?? Math.max(renderedHeight ?? 0, limits.minHeight),
},
}
capturePointer(event, true)
}
const handleResizeMove = (event: ReactPointerEvent<HTMLElement>) => {
const resize = resizeRef.current
if (!resize || resize.pointerId !== event.pointerId) return
const dx = event.clientX - resize.startX
const dy = event.clientY - resize.startY
let { width, height } = resize.size
let { x, y } = resize.position
if (resize.direction.includes('e')) width = resize.size.width + dx
if (resize.direction.includes('s')) height = resize.size.height + dy
if (resize.direction.includes('w')) width = resize.size.width - dx
if (resize.direction.includes('n')) height = resize.size.height - dy
const size = clampSize({ width, height }, limits)
if (resize.direction.includes('w')) {
x = resize.position.x + (resize.size.width - size.width)
}
if (resize.direction.includes('n')) {
y = resize.position.y + (resize.size.height - (size.height ?? height))
}
const position = clampPosition({ x, y }, size.width)
setState((previous) => ({ ...previous, size, position }))
}
const handleResizeEnd = (event: ReactPointerEvent<HTMLElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return
resizeRef.current = null
capturePointer(event, false)
}
const dragHandleProps = {
title: t('Drag to move'),
onPointerDown: handleDragStart,
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
}
const style = {
position: 'fixed' as const,
left: state.position.x,
top: state.position.y,
}
if (state.collapsed) {
return (
<div
{...dragHandleProps}
style={style}
className={cn(
'bg-background border-border z-50 flex cursor-move touch-none items-center gap-2 rounded-full border py-1 pr-1 pl-3 shadow-lg select-none',
props.className
)}
>
<GripHorizontal
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='text-sm font-semibold'>{props.title}</span>
{props.badge}
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Expand panel')}
onClick={() =>
setState((previous) => ({ ...previous, collapsed: false }))
}
>
<ChevronsUpDown aria-hidden='true' />
</Button>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Close panel')}
onClick={props.onClose}
>
<X aria-hidden='true' />
</Button>
</div>
)
}
return (
<aside
ref={windowRef}
role='complementary'
aria-labelledby={`${id}-title`}
style={{
...style,
width: state.size.width,
height: state.size.height,
maxHeight: `calc(100vh - ${VIEWPORT_MARGIN * 2}px)`,
}}
className={cn(
'bg-background border-border z-50 flex flex-col overflow-hidden rounded-xl border shadow-xl',
props.className
)}
>
<header
{...dragHandleProps}
className='border-border/70 flex cursor-move touch-none items-center gap-2 border-b px-4 py-3 select-none'
>
<GripHorizontal
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<h3 id={`${id}-title`} className='text-sm font-semibold'>
{props.title}
</h3>
{props.badge}
<div className='ml-auto flex items-center gap-1'>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Collapse panel')}
onClick={() =>
setState((previous) => ({ ...previous, collapsed: true }))
}
>
<ChevronsUpDown aria-hidden='true' />
</Button>
<Button
type='button'
variant='ghost'
size='icon-xs'
aria-label={t('Close panel')}
onClick={props.onClose}
>
<X aria-hidden='true' />
</Button>
</div>
</header>
<div className='min-h-0 flex-1 overflow-y-auto px-4 py-3'>
{props.children}
</div>
{props.footer && (
<footer className='border-border/70 border-t px-4 py-3'>
{props.footer}
</footer>
)}
{RESIZE_DIRECTIONS.map((direction) => (
<div
key={direction}
data-slot='floating-window-resize'
data-direction={direction}
title={t('Drag to resize')}
aria-hidden='true'
className={cn(
'absolute z-10 touch-none',
RESIZE_HANDLE_CLASS[direction]
)}
onPointerDown={(event) => handleResizeStart(event, direction)}
onPointerMove={handleResizeMove}
onPointerUp={handleResizeEnd}
onPointerCancel={handleResizeEnd}
/>
))}
</aside>
)
}
@@ -0,0 +1,175 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, test, vi } from 'vitest'
import { FloatingWindow } from '@/components/floating-window'
afterEach(() => {
window.localStorage.clear()
})
function renderWindow(props?: { storageKey?: string; onClose?: () => void }) {
return render(
<FloatingWindow
title='Scratch pad'
defaultPosition={{ x: 100, y: 20 }}
defaultWidth={300}
storageKey={props?.storageKey}
onClose={props?.onClose ?? vi.fn()}
footer={<span>footer</span>}
>
<p>body</p>
</FloatingWindow>
)
}
test('renders fixed at the default position and follows a header drag, remembering where it was dropped', () => {
renderWindow({ storageKey: 'scratch' })
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
expect(window_).toHaveStyle({
position: 'fixed',
left: '100px',
top: '20px',
width: '300px',
})
expect(screen.getByText('body')).toBeVisible()
expect(screen.getByText('footer')).toBeVisible()
const handle = screen.getByTitle('Drag to move')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 1,
clientX: 120,
clientY: 40,
})
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 220, clientY: 90 })
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 220, clientY: 90 })
expect(window_).toHaveStyle({ left: '200px', top: '70px' })
expect(
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
).toEqual({ x: 200, y: 70, width: 300, collapsed: false })
})
function resizeHandle(direction: string) {
const handle = document.querySelector<HTMLElement>(
`[data-slot="floating-window-resize"][data-direction="${direction}"]`
)
if (!handle) throw new Error(`missing ${direction} resize handle`)
return handle
}
test('dragging the bottom-right corner grows both dimensions from the rendered size and persists them', () => {
renderWindow({ storageKey: 'scratch' })
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
const handle = resizeHandle('se')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 3,
clientX: 400,
clientY: 300,
})
fireEvent.pointerMove(handle, { pointerId: 3, clientX: 450, clientY: 380 })
fireEvent.pointerUp(handle, { pointerId: 3, clientX: 450, clientY: 380 })
// jsdom renders no height, so the vertical start is the minimum height.
expect(window_).toHaveStyle({ width: '350px', height: '280px' })
expect(
JSON.parse(window.localStorage.getItem('floating-window:scratch') ?? '{}')
).toEqual({ x: 100, y: 20, width: 350, height: 280, collapsed: false })
})
test('dragging the left edge keeps the right edge in place and never shrinks below the minimum width', () => {
renderWindow()
const window_ = screen.getByRole('complementary', { name: 'Scratch pad' })
const handle = resizeHandle('w')
fireEvent.pointerDown(handle, {
button: 0,
pointerId: 4,
clientX: 100,
clientY: 200,
})
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 60, clientY: 200 })
expect(window_).toHaveStyle({ left: '60px', width: '340px' })
fireEvent.pointerMove(handle, { pointerId: 4, clientX: 300, clientY: 200 })
fireEvent.pointerUp(handle, { pointerId: 4, clientX: 300, clientY: 200 })
expect(window_).toHaveStyle({ left: '120px', width: '280px' })
})
test('a stored size is restored and clamped to the viewport', () => {
window.localStorage.setItem(
'floating-window:scratch',
JSON.stringify({ x: 10, y: 10, width: 5000, height: 120 })
)
renderWindow({ storageKey: 'scratch' })
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ width: `${window.innerWidth - 16}px`, height: '200px' })
})
test('a stored position outside the viewport is clamped back inside on mount', () => {
window.localStorage.setItem(
'floating-window:scratch',
JSON.stringify({ x: 5000, y: -50 })
)
renderWindow({ storageKey: 'scratch' })
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ left: `${window.innerWidth - 300 - 8}px`, top: '8px' })
})
test('collapses to a pill that keeps the title and reopens, and the close control reports to the caller', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
renderWindow({ onClose })
await user.click(screen.getByRole('button', { name: 'Collapse panel' }))
expect(
screen.queryByRole('complementary', { name: 'Scratch pad' })
).not.toBeInTheDocument()
expect(screen.getByText('Scratch pad')).toBeVisible()
expect(screen.queryByText('body')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Expand panel' }))
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toBeVisible()
await user.click(screen.getByRole('button', { name: 'Close panel' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
test('pressing a header button does not start a drag', () => {
const onClose = vi.fn()
renderWindow({ onClose })
const close = screen.getByRole('button', { name: 'Close panel' })
fireEvent.pointerDown(close, {
button: 0,
pointerId: 2,
clientX: 380,
clientY: 30,
})
fireEvent.pointerMove(screen.getByTitle('Drag to move'), {
pointerId: 2,
clientX: 100,
clientY: 300,
})
expect(
screen.getByRole('complementary', { name: 'Scratch pad' })
).toHaveStyle({ left: '100px', top: '20px' })
})
@@ -21,11 +21,12 @@ import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
import { useNotifications } from '@/hooks/use-notifications'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { defaultTopNavLinks } from '../config/top-nav.config'
import { type TopNavLink } from '../types'
import type { TopNavLink } from '../types'
import { Header } from './header'
import { SystemBrand } from './system-brand'
import { TopNav } from './top-nav'
@@ -111,40 +112,43 @@ export function AppHeader({
const notifications = useNotifications()
return (
<>
<Header>
<Header>
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1'>
<SystemBrand variant='inline' />
<SystemUpdateAction presentation='version' />
</div>
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{leftContent ? (
<div className='ms-2 flex items-center'>{leftContent}</div>
) : null}
{rightContent ?? (
<div className='ms-auto flex items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && <Search />}
{showNotifications && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
<LanguageSwitcher />
{showConfigDrawer && <ConfigDrawer />}
{showProfileDropdown && <ProfileDropdown />}
</div>
)}
</Header>
</>
{rightContent ?? (
<div className='ms-auto flex shrink-0 items-center gap-1 sm:gap-2'>
{showTopNav && (
<div className='me-1 hidden lg:block'>
<TopNav links={links} />
</div>
)}
{showSearch && (
<Search className='w-8 flex-none [&>span]:hidden sm:[&>span]:inline' />
)}
{showNotifications && (
<NotificationPopover
open={notifications.popoverOpen}
onOpenChange={notifications.setPopoverOpen}
unreadCount={notifications.unreadCount}
activeTab={notifications.activeTab}
onTabChange={notifications.setActiveTab}
notice={notifications.notice}
announcements={notifications.announcements}
loading={notifications.loading}
/>
)}
<LanguageSwitcher />
{showConfigDrawer && <ConfigDrawer />}
{showProfileDropdown && <ProfileDropdown />}
</div>
)}
</Header>
)
}
@@ -48,6 +48,7 @@ import {
resolveChatUrl,
type ChatPreset,
} from '@/features/chat/lib/chat-links'
import { handleServerError } from '@/lib/handle-server-error'
import { normalizeHref } from '../lib/url-utils'
import type { NavChatPresets } from '../types'
@@ -194,7 +195,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
: t(
'Unable to prepare chat link. Please ensure you have an enabled API key.'
)
toast.error(message)
handleServerError(error, message)
return
} finally {
loadingPresetIdRef.current = null
@@ -149,6 +149,22 @@ function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
)
}
export function ServiceSupportAttribution() {
const { t } = useTranslation()
return (
<span className='text-muted-foreground/60 text-xs'>
<a
href='https://www.crash.work/'
target='_blank'
rel='noopener noreferrer'
className='text-foreground/70 hover:text-foreground font-medium transition-colors'
>
{t('Service support provided by Crash Workshop Cloud Computing')}
</a>
</span>
)
}
export function Footer(props: FooterProps) {
const { t } = useTranslation()
const {
@@ -238,6 +254,7 @@ export function Footer(props: FooterProps) {
/>
<div className='border-border/60 text-muted-foreground/45 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
<LegalLinks />
<ServiceSupportAttribution />
<ProjectAttribution currentYear={currentYear} inline />
</div>
</div>
@@ -272,14 +289,14 @@ export function Footer(props: FooterProps) {
{/* Links columns */}
{isDemoSiteMode && (
<div className='grid grid-cols-3 gap-8 md:gap-16'>
{displayColumns.map((column, index) => (
<div key={index}>
{displayColumns.map((column) => (
<div key={column.title}>
<p className='text-muted-foreground/50 mb-3 text-xs font-medium tracking-wider uppercase'>
{t(column.title)}
</p>
<ul className='space-y-2.5'>
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
{column.links.map((link) => (
<li key={`${link.text}:${link.href}`}>
<FooterLinkItem link={link} />
</li>
))}
@@ -301,6 +318,7 @@ export function Footer(props: FooterProps) {
<LegalLinks leadingSeparator />
</div>
<ProjectAttribution currentYear={currentYear} />
<ServiceSupportAttribution />
</div>
</div>
</footer>
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
@@ -27,6 +27,7 @@ import { ProfileDropdown } from '@/components/profile-dropdown'
import { ThemeSwitch } from '@/components/theme-switch'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { SystemUpdateAction } from '@/features/system-update/system-update-action'
import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
@@ -98,6 +99,29 @@ export function PublicHeader(props: PublicHeaderProps) {
const displaySiteName = customSiteName || systemName
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
let logoContent: ReactNode = (
<HeaderLogo
src={systemLogo}
loading={loading}
logoLoaded={logoLoaded}
className='size-full rounded-lg object-contain'
/>
)
if (customLogo) logoContent = customLogo
if (loading) logoContent = <Skeleton className='size-full rounded-lg' />
let authContent = (
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
render={<Link to='/sign-in' />}
>
{t('Sign in')}
</Button>
)
if (isAuthenticated) authContent = <ProfileDropdown />
if (loading) authContent = <Skeleton className='h-8 w-20 rounded-lg' />
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20)
onScroll()
@@ -184,52 +208,52 @@ export function PublicHeader(props: PublicHeaderProps) {
>
<nav
className={cn(
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
'flex items-center justify-between gap-2 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
scrolled
? 'bg-background/60 ring-border/50 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.08),0_0_0_0.5px_rgba(0,0,0,0.02)] ring-[0.5px] backdrop-blur-2xl dark:shadow-[0_2px_16px_-6px_rgba(0,0,0,0.4)]'
: 'h-16 px-2'
)}
>
{/* Logo */}
<Link
to={homeUrl}
className='group flex shrink-0 items-center gap-2.5'
>
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
{loading ? (
<Skeleton className='size-full rounded-lg' />
) : customLogo ? (
customLogo
) : (
<HeaderLogo
src={systemLogo}
loading={loading}
logoLoaded={logoLoaded}
className='size-full rounded-lg object-contain'
/>
)}
</div>
<span className='text-sm font-semibold tracking-tight'>
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
</span>
</Link>
<div className='@container/system-brand flex min-w-0 flex-1 items-center gap-1 lg:min-w-36'>
<Link
to={homeUrl}
className='group flex min-w-0 items-center gap-2.5'
>
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
{logoContent}
</div>
<span
className='max-w-48 truncate text-sm font-semibold tracking-tight'
title={displaySiteName}
>
{loading ? (
<Skeleton className='h-4 w-16' />
) : (
displaySiteName
)}
</span>
</Link>
<SystemUpdateAction presentation='version' />
</div>
{/* Desktop nav */}
<div className='hidden items-center gap-0.5 sm:flex'>
{links.map((link, i) => {
<div className='hidden min-w-0 items-center gap-0.5 lg:flex'>
{links.map((link) => {
const isActive = pathname === link.href
if (link.external) {
return (
<a
key={i}
key={`${link.title}:${link.href}`}
href={link.href}
title={t(link.title)}
target='_blank'
rel='noopener noreferrer'
aria-disabled={link.disabled}
tabIndex={link.disabled ? -1 : undefined}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'text-muted-foreground hover:text-foreground rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
'text-muted-foreground hover:text-foreground min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
link.disabled && 'pointer-events-none opacity-50'
)}
>
@@ -239,12 +263,13 @@ export function PublicHeader(props: PublicHeaderProps) {
}
return (
<Link
key={i}
key={`${link.title}:${link.href}`}
to={link.href}
title={t(link.title)}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
'min-w-0 truncate rounded-lg px-3 py-1.5 text-sm font-medium transition-colors duration-200',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground',
@@ -280,25 +305,13 @@ export function PublicHeader(props: PublicHeaderProps) {
{showAuthButtons && (
<>
<div className='bg-border/40 mx-1 h-4 w-px' />
{loading ? (
<Skeleton className='h-8 w-20 rounded-lg' />
) : isAuthenticated ? (
<ProfileDropdown />
) : (
<Button
size='sm'
className='h-8 rounded-lg px-3.5 text-xs font-medium'
render={<Link to='/sign-in' />}
>
{t('Sign in')}
</Button>
)}
{authContent}
</>
)}
</div>
{/* Mobile: compact actions + hamburger */}
<div className='flex items-center gap-2 sm:hidden'>
<div className='flex shrink-0 items-center gap-2 lg:hidden'>
{showThemeSwitch && <ThemeSwitch />}
{showAuthButtons && !loading && isAuthenticated && (
<ProfileDropdown />
@@ -340,7 +353,7 @@ export function PublicHeader(props: PublicHeaderProps) {
{/* Mobile full-screen overlay */}
<div
className={cn(
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] lg:pointer-events-none lg:hidden',
mobileOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
@@ -364,7 +377,7 @@ export function PublicHeader(props: PublicHeaderProps) {
if (link.external) {
return (
<a
key={i}
key={`${link.title}:${link.href}`}
href={link.href}
target='_blank'
rel='noopener noreferrer'
@@ -380,7 +393,7 @@ export function PublicHeader(props: PublicHeaderProps) {
}
return (
<Link
key={i}
key={`${link.title}:${link.href}`}
to={link.href}
disabled={link.disabled}
onClick={(event) => handleNavLinkClick(event, link, true)}
@@ -61,11 +61,11 @@ export function SystemBrand(props: SystemBrandProps) {
to='/'
aria-label={t('Go to home')}
className={cn(
'text-foreground inline-flex h-7 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
'text-foreground inline-flex h-7 min-w-0 items-center gap-1.5 rounded-md px-1.5 text-sm font-medium transition-colors outline-none select-none',
'hover:bg-accent focus-visible:ring-ring/40 focus-visible:ring-2'
)}
>
<div className='flex size-5 items-center justify-center overflow-hidden rounded-md'>
<div className='flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-md'>
<img
src={logo}
alt={t('Logo')}
@@ -16,9 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import type { TFunction } from 'i18next'
import {
Box,
Route,
CreditCard,
Layout,
Settings,
@@ -32,6 +33,7 @@ import { getBillingSectionNavItems } from '@/features/system-settings/billing/se
import { getContentSectionNavItems } from '@/features/system-settings/content/section-registry.tsx'
import { getModelsSectionNavItems } from '@/features/system-settings/models/section-registry.tsx'
import { getOperationsSectionNavItems } from '@/features/system-settings/operations/section-registry.tsx'
import { getPolicySectionNavItems } from '@/features/system-settings/request-policies/section-registry'
import { getSecuritySectionNavItems } from '@/features/system-settings/security/section-registry.tsx'
import { getSiteSectionNavItems } from '@/features/system-settings/site/section-registry.tsx'
@@ -66,10 +68,15 @@ function getSystemSettingsNavGroups(t: TFunction): NavGroup[] {
items: getBillingSectionNavItems(t),
},
{
title: t('Models & Routing'),
title: t('Models'),
icon: Box,
items: getModelsSectionNavItems(t),
},
{
title: t('Request policies'),
icon: Route,
items: getPolicySectionNavItems(t),
},
{
title: t('Security & Limits'),
icon: ShieldAlert,
+58 -2
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Add01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowRightLeft } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -41,6 +42,11 @@ import { cn } from '@/lib/utils'
export type Option = {
label: string
value: string
/**
* Secondary text shown beside the option in the dropdown. Chips for hinted
* values also carry a marker icon whose tooltip repeats the hint.
*/
hint?: string
}
interface MultiSelectProps {
@@ -139,6 +145,14 @@ export function MultiSelect(props: MultiSelectProps) {
return map
}, [props.options])
const hintMap = React.useMemo(() => {
const map = new Map<string, string>()
for (const option of props.options) {
if (option.hint) map.set(option.value, option.hint)
}
return map
}, [props.options])
const trimmedInput = inputValue.trim()
const inputMatchesExisting =
trimmedInput.length > 0 &&
@@ -164,7 +178,7 @@ export function MultiSelect(props: MultiSelectProps) {
if (canCreate) {
set.add(trimmedInput)
}
return Array.from(set)
return [...set]
}, [props.options, props.selected, canCreate, trimmedInput])
const addValues = React.useCallback(
@@ -198,6 +212,25 @@ export function MultiSelect(props: MultiSelectProps) {
setInputValue(value)
}
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>): void => {
if (!props.allowCreate || props.disabled) return
const pasted = event.clipboardData.getData('text/plain')
if (!COMMA_REGEX.test(pasted)) return
event.preventDefault()
const input = event.currentTarget
const value =
input.value.slice(0, input.selectionStart ?? input.value.length) +
pasted +
input.value.slice(input.selectionEnd ?? input.value.length)
// A pasted batch is complete, including its final value. Read the clipboard
// before the single-line input can strip newline separators.
addValues(value.split(COMMA_REGEX))
setInputValue('')
}
const handleValueChange = (next: string[]) => {
props.onChange(next)
// When an item is picked (multiple mode), Base UI keeps the input but most
@@ -282,6 +315,7 @@ export function MultiSelect(props: MultiSelectProps) {
<>
{visibleValues.map((value) => {
const label = labelMap.get(value) ?? value
const hint = hintMap.get(value)
return (
<ComboboxChip key={value}>
{props.copyChipOnClick ? (
@@ -299,6 +333,15 @@ export function MultiSelect(props: MultiSelectProps) {
) : (
<span className='max-w-[16rem] truncate'>{label}</span>
)}
{hint && (
<span
title={hint}
aria-hidden='true'
className='text-muted-foreground inline-flex shrink-0'
>
<ArrowRightLeft className='size-3' />
</span>
)}
</ComboboxChip>
)
})}
@@ -346,6 +389,7 @@ export function MultiSelect(props: MultiSelectProps) {
: undefined
}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
aria-label={placeholder}
/>
</ComboboxChips>
@@ -356,11 +400,13 @@ export function MultiSelect(props: MultiSelectProps) {
{(item: string) => {
const isCreate = canCreate && item === trimmedInput
const label = labelMap.get(item) ?? item
const hint = hintMap.get(item)
return (
<ComboboxItem
key={item}
value={item}
className={isCreate ? 'text-foreground' : undefined}
aria-description={hint}
>
{isCreate ? (
<>
@@ -377,7 +423,17 @@ export function MultiSelect(props: MultiSelectProps) {
</span>
</>
) : (
<span className='truncate'>{label}</span>
<>
<span className='truncate'>{label}</span>
{hint && (
<span
aria-hidden='true'
className='text-muted-foreground ml-auto max-w-40 shrink-0 truncate text-xs'
>
{hint}
</span>
)}
</>
)}
</ComboboxItem>
)
@@ -0,0 +1,53 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
import { MultiSelect } from '@/components/multi-select'
test('hinted options show the hint in the dropdown and mark their chips without changing the chip name', async () => {
const user = userEvent.setup()
render(
<MultiSelect
options={[
{ value: 'alias', label: 'alias', hint: 'Redirects to upstream' },
{ value: 'plain', label: 'plain' },
]}
selected={['alias', 'plain']}
onChange={() => undefined}
copyChipOnClick
/>
)
expect(screen.getByRole('button', { name: 'alias' })).toBeVisible()
expect(screen.getByTitle('Redirects to upstream')).toHaveAttribute(
'aria-hidden',
'true'
)
expect(screen.getAllByTitle('Redirects to upstream')).toHaveLength(1)
await user.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: /^alias/ })).toHaveTextContent(
'Redirects to upstream'
)
expect(screen.getByRole('option', { name: 'plain' })).not.toHaveTextContent(
'Redirects to upstream'
)
})
@@ -0,0 +1,159 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { MultiSelect } from '@/components/multi-select'
const options = [
{ value: 'a', label: 'a' },
{ value: 'c', label: 'c' },
]
function Fixture(props: {
initialSelected?: string[]
allowCreate?: boolean
disabled?: boolean
}) {
const [selected, setSelected] = useState(props.initialSelected ?? [])
return (
<>
<MultiSelect
options={options}
selected={selected}
onChange={setSelected}
allowCreate={props.allowCreate ?? true}
disabled={props.disabled}
/>
<output aria-label='Selected values'>{selected.join(',')}</output>
</>
)
}
describe('multi-select batch paste', () => {
it.each(['a,b,c', 'a,b,c', 'a\nb\nc', 'a\r\nb\r\nc'])(
'adds every pasted value including the final item without a trailing separator: %j',
async (text) => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste(text)
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^a,b,c$/
)
expect(input).toHaveValue('')
expect(input).toHaveFocus()
}
)
it('preserves existing selections and ignores duplicate, blank, and trailing entries', async () => {
const user = userEvent.setup()
render(<Fixture initialSelected={['existing', 'a']} />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste(' a,, b,b\r\nc, ')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^existing,a,b,c$/
)
expect(input).toHaveValue('')
})
it('replaces the selected draft text when pasting a batch and preserves surrounding text', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole<HTMLInputElement>('combobox')
await user.type(input, 'model-old-end')
input.setSelectionRange(6, 9)
await user.paste('a,b')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^model-a,b-end$/
)
expect(input).toHaveValue('')
})
it('keeps the last manually typed value as a draft until a separator is typed', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.type(input, 'a,b,c')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^a,b$/)
expect(input).toHaveValue('c')
await user.type(input, ',')
expect(screen.getByLabelText('Selected values')).toHaveTextContent(
/^a,b,c$/
)
expect(input).toHaveValue('')
})
it('keeps a single pasted value available for search and explicit selection', async () => {
const user = userEvent.setup()
render(<Fixture />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('c')
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('c')
await user.click(screen.getByRole('option', { name: 'c' }))
expect(screen.getByLabelText('Selected values')).toHaveTextContent(/^c$/)
expect(input).toHaveValue('')
})
it('leaves pasted text as search input when custom creation is disabled', async () => {
const user = userEvent.setup()
render(<Fixture allowCreate={false} />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('a,b,c')
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('a,b,c')
})
it('does not add pasted values when the control is disabled', async () => {
const user = userEvent.setup()
render(<Fixture disabled />)
const input = screen.getByRole('combobox')
await user.click(input)
await user.paste('a,b,c')
expect(input).toBeDisabled()
expect(screen.getByLabelText('Selected values')).toBeEmptyDOMElement()
expect(input).toHaveValue('')
})
})
+3 -4
View File
@@ -25,6 +25,7 @@ import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { logout } from '@/features/auth/api'
import { clearAuthenticatedClientState } from '@/lib/auth-session'
import { handleServerError } from '@/lib/handle-server-error'
interface SignOutDialogProps {
open: boolean
@@ -42,7 +43,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
try {
const response = await logout()
if (!response.success) {
toast.error(response.message || t('Failed to sign out session'))
handleServerError(response, t('Failed to sign out session'))
return
}
@@ -50,9 +51,7 @@ export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
toast.success(t('Signed out'))
void navigate({ to: '/sign-in', replace: true })
} catch (error: unknown) {
toast.error(
error instanceof Error ? error.message : t('Failed to sign out session')
)
handleServerError(error, t('Failed to sign out session'))
} finally {
setIsSigningOut(false)
}
@@ -21,7 +21,10 @@ import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { Dialog } from '@/components/dialog'
import { Combobox } from '../combobox'
import { Sheet, SheetContent, SheetTitle } from '../sheet'
const options = [
{ value: 'openai', label: 'OpenAI' },
@@ -46,6 +49,90 @@ function Fixture() {
}
describe('searchable single selection', () => {
it.each(['dialog', 'sheet'] as const)(
'keeps the options closed when a %s autofocuses the input, then opens on click',
async (container) => {
const field = <Fixture />
render(
container === 'dialog' ? (
<Dialog open title='Choose a provider'>
{field}
</Dialog>
) : (
<Sheet open>
<SheetContent>
<SheetTitle>Choose a provider</SheetTitle>
{field}
</SheetContent>
</Sheet>
)
)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await waitFor(() => expect(input).toHaveFocus())
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(input).toHaveValue('OpenAI')
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
await user.click(input)
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
await user.click(screen.getByRole('option', { name: 'Google' }))
await waitFor(() => expect(input).toHaveValue('Google'))
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByRole('dialog')).toBeVisible()
}
)
it('keeps options closed on Tab focus and opens them with the arrow key', async () => {
render(<Fixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-expanded', 'false')
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
await user.keyboard('{Escape}')
expect(input).toHaveAttribute('aria-expanded', 'false')
expect(input).toHaveValue('OpenAI')
})
it('opens and filters when typing into a focused, closed input', async () => {
render(<Fixture />)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
await user.keyboard('g')
expect(input).toHaveValue('g')
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
expect(
screen.queryByRole('option', { name: 'OpenAI' })
).not.toBeInTheDocument()
})
it.each([true, false])(
'honors explicit openOnFocus=%s',
async (openOnFocus) => {
render(
<Combobox
options={options}
value='openai'
aria-label='Provider'
openOnFocus={openOnFocus}
/>
)
const user = userEvent.setup()
const input = screen.getByRole('combobox', { name: 'Provider' })
await user.tab()
expect(input).toHaveFocus()
expect(input).toHaveAttribute('aria-expanded', String(openOnFocus))
if (!openOnFocus) {
await user.click(screen.getByRole('button', { name: 'Provider' }))
expect(screen.getByRole('option', { name: 'Google' })).toBeVisible()
}
}
)
it('searches labels and values without committing text, shows empty results, and restores the selection on Escape', async () => {
render(<Fixture />)
const user = userEvent.setup()
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useForm } from 'react-hook-form'
import { describe, expect, it } from 'vitest'
import { FieldLabel } from '../field'
import { Form, FormControl, FormField, FormItem, FormLabel } from '../form'
import { Input } from '../input'
import { Label } from '../label'
function FormLabelFixture() {
const form = useForm({ defaultValues: { name: '' } })
return (
<Form {...form}>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel required>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
</Form>
)
}
describe('required field labels', () => {
it.each([Label, FieldLabel])(
'updates the required marker while preserving the associated control',
async (LabelComponent) => {
const view = render(
<>
<LabelComponent htmlFor='model' required>
Model
</LabelComponent>
<Input id='model' />
</>
)
const user = userEvent.setup()
const input = screen.getByRole('textbox', { name: 'Model *' })
await user.click(screen.getByText('*'))
expect(input).toHaveFocus()
view.rerender(
<>
<LabelComponent htmlFor='model' required={false}>
Model
</LabelComponent>
<Input id='model' />
</>
)
expect(screen.queryByText('*')).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Model' })).toBe(input)
}
)
it('keeps the required form label associated with its React Hook Form control', async () => {
render(<FormLabelFixture />)
const user = userEvent.setup()
const input = screen.getByRole('textbox', { name: 'Name *' })
await user.click(screen.getByText('*'))
expect(input).toHaveFocus()
await user.type(input, 'Example')
expect(input).toHaveValue('Example')
})
})
+117 -52
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { Check, ChevronsUpDown } from 'lucide-react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
@@ -38,12 +39,15 @@ interface ComboboxInputProps {
placeholder?: string
emptyText?: string
className?: string
dropdownClassName?: string
id?: string
allowCustomValue?: boolean
openOnFocus?: boolean
disabled?: boolean
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
'aria-label'?: string
'aria-labelledby'?: string
'aria-invalid'?: React.AriaAttributes['aria-invalid']
}
export function ComboboxInput({
@@ -53,12 +57,15 @@ export function ComboboxInput({
placeholder = 'Select or type...',
emptyText = 'No option found.',
className,
dropdownClassName,
id,
allowCustomValue = false,
openOnFocus = true,
disabled = false,
onKeyDown,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-invalid': ariaInvalid,
}: ComboboxInputProps) {
const { t } = useTranslation()
const listId = React.useId()
@@ -69,6 +76,16 @@ export function ComboboxInput({
const containerRef = React.useRef<HTMLDivElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const listRef = React.useRef<HTMLUListElement>(null)
const dropdownRef = React.useRef<HTMLDivElement>(null)
// The dropdown is portaled so scrolling ancestors cannot clip it. It goes
// into the enclosing dialog when there is one, which keeps a modal parent
// from treating option clicks as outside presses.
const [dropdown, setDropdown] = React.useState<{
container: HTMLElement
top: number
left: number
width: number
} | null>(null)
const pointerFocusRef = React.useRef(false)
const selectedOption = React.useMemo(
() => options.find((option) => option.value === value),
@@ -96,9 +113,11 @@ export function ComboboxInput({
if (!open) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
!containerRef.current.contains(target) &&
!dropdownRef.current?.contains(target)
) {
setOpen(false)
setSearchValue('')
@@ -109,6 +128,34 @@ export function ComboboxInput({
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
// Position the portaled dropdown under the input and follow scroll/resize.
React.useEffect(() => {
if (!open) {
setDropdown(null)
return
}
const input = inputRef.current
if (!input) return
const container =
input.closest<HTMLElement>('[role="dialog"]') ?? document.body
const measure = () => {
const rect = input.getBoundingClientRect()
setDropdown({
container,
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}
measure()
window.addEventListener('scroll', measure, true)
window.addEventListener('resize', measure)
return () => {
window.removeEventListener('scroll', measure, true)
window.removeEventListener('resize', measure)
}
}, [open])
const handleSelect = (selectedValue: string) => {
onValueChange(selectedValue)
setOpen(false)
@@ -172,6 +219,7 @@ export function ComboboxInput({
const showDropdown =
open &&
!disabled &&
(filteredOptions.length > 0 || (allowCustomValue && searchValue.trim()))
return (
@@ -181,8 +229,10 @@ export function ComboboxInput({
id={id}
type='text'
role='combobox'
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-invalid={ariaInvalid}
aria-expanded={!!showDropdown}
aria-controls={
showDropdown && filteredOptions.length > 0 ? listId : undefined
@@ -234,59 +284,74 @@ export function ComboboxInput({
/>
<ChevronsUpDown className='pointer-events-none absolute top-1/2 right-3 size-4 shrink-0 -translate-y-1/2 opacity-50' />
{showDropdown && (
<div className='bg-popover text-popover-foreground absolute top-full z-100 mt-1 w-full rounded-md border shadow-md'>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
id={listId}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
{showDropdown &&
dropdown &&
createPortal(
<div
ref={dropdownRef}
style={{
position: 'fixed',
top: dropdown.top,
left: dropdown.left,
width: dropdown.width,
}}
className={cn(
'bg-popover text-popover-foreground z-100 rounded-md border shadow-md',
dropdownClassName
)}
>
{filteredOptions.length > 0 ? (
<ul
ref={listRef}
id={listId}
role='listbox'
className='max-h-[200px] overflow-y-auto p-1'
>
{filteredOptions.map((option, index) => (
<li
key={option.value}
id={`${listId}-${index}`}
role='option'
aria-selected={value === option.value}
data-highlighted={index === highlightedIndex}
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none',
index === highlightedIndex &&
'bg-accent text-accent-foreground',
value === option.value && 'font-medium'
)}
/>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent blur
handleSelect(option.value)
}}
>
<Check
className={cn(
'size-4 shrink-0',
value === option.value ? 'opacity-100' : 'opacity-0'
)}
/>
{option.icon && <span aria-hidden>{option.icon}</span>}
<span className='truncate'>{option.label}</span>
</li>
))}
</ul>
) : (
<div className='px-2 py-6 text-center text-sm'>
{t(emptyText)}
{allowCustomValue && searchValue.trim() && (
<div className='text-muted-foreground mt-1 text-xs'>
{t('Press Enter to use "{{value}}"', {
value: searchValue.trim(),
})}
</div>
)}
</div>
)}
</div>,
dropdown.container
)}
</div>
)
}
+7 -3
View File
@@ -50,6 +50,7 @@ type LegacyComboboxProps = {
allowCustomValue?: boolean
showSelectedIcon?: boolean
className?: string
dropdownClassName?: string
id?: string
openOnFocus?: boolean
disabled?: boolean
@@ -79,6 +80,7 @@ function Combobox(
id={props.id}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
aria-invalid={props['aria-invalid']}
onKeyDown={props.onKeyDown}
options={props.options}
value={props.value ?? ''}
@@ -86,6 +88,7 @@ function Combobox(
placeholder={props.searchPlaceholder ?? props.placeholder}
emptyText={props.emptyText}
className={props.className}
dropdownClassName={props.dropdownClassName}
allowCustomValue={props.allowCustomValue}
openOnFocus={props.openOnFocus}
/>
@@ -113,9 +116,9 @@ function OptionCombobox(props: LegacyComboboxProps) {
onInputValueChange={(value, details) => {
if (details.reason === 'input-change') setSearch(value)
}}
onOpenChange={(nextOpen) => {
onOpenChange={(nextOpen, details) => {
setOpen(nextOpen)
setSearch('')
if (details.reason !== 'input-change') setSearch('')
}}
onValueChange={(option) => {
if (option) props.onValueChange?.(option.value)
@@ -137,7 +140,8 @@ function OptionCombobox(props: LegacyComboboxProps) {
onBlur={props.onBlur}
onKeyDown={props.onKeyDown}
onFocus={() => {
if (props.openOnFocus !== false) setOpen(true)
// Dialog autofocus should not expand a select-style combobox.
if (props.openOnFocus) setOpen(true)
}}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
+18 -2
View File
@@ -22,7 +22,12 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
function Label({ className, ...props }: React.ComponentProps<'label'>) {
function Label({
className,
children,
required = false,
...props
}: React.ComponentProps<'label'> & { required?: boolean }) {
return (
<label
data-slot='label'
@@ -31,7 +36,18 @@ function Label({ className, ...props }: React.ComponentProps<'label'>) {
className
)}
{...props}
/>
>
{required ? (
<span>
{children}{' '}
<span data-slot='required-indicator' className='text-destructive'>
*
</span>
</span>
) : (
children
)}
</label>
)
}
+23 -5
View File
@@ -26,6 +26,8 @@ import { useMemo } from 'react'
import { cn } from '@/lib/utils'
interface MarkdownProps {
/** Resolve relative document links against their source, never the dashboard URL. */
baseUrl?: string
breaks?: boolean
children: string
className?: string
@@ -718,7 +720,7 @@ const markdownParser = new Marked({
markdownParser.use(...markdownExtensions)
function addExternalLinkAttributes(html: string): string {
function addExternalLinkAttributes(html: string, baseUrl?: string): string {
if (typeof window === 'undefined') {
return html
}
@@ -727,6 +729,18 @@ function addExternalLinkAttributes(html: string): string {
template.innerHTML = html
template.content.querySelectorAll('a[href]').forEach((link) => {
if (baseUrl) {
try {
const url = new URL(link.getAttribute('href') ?? '', baseUrl)
if (['http:', 'https:', 'mailto:'].includes(url.protocol)) {
link.setAttribute('href', url.href)
} else {
link.removeAttribute('href')
}
} catch {
link.removeAttribute('href')
}
}
link.setAttribute('target', '_blank')
link.setAttribute('rel', 'noopener noreferrer')
})
@@ -734,20 +748,24 @@ function addExternalLinkAttributes(html: string): string {
return template.innerHTML
}
function renderMarkdown(markdown: string, breaks = false): string {
function renderMarkdown(
markdown: string,
breaks = false,
baseUrl?: string
): string {
const parsedHtml = markdownParser.parse(markdown, {
...markdownOptions,
breaks,
})
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)
return addExternalLinkAttributes(html)
return addExternalLinkAttributes(html, baseUrl)
}
export function Markdown(props: MarkdownProps) {
const html = useMemo(
() => renderMarkdown(props.children, props.breaks),
[props.breaks, props.children]
() => renderMarkdown(props.children, props.breaks, props.baseUrl),
[props.breaks, props.children, props.baseUrl]
)
return (