mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-04 22:42:41 +00:00
Compare commits
17 Commits
cli/v0.2.1
...
feat/chat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc2934a58 | ||
|
|
4541bc6f65 | ||
|
|
85ca6494c6 | ||
|
|
200c866c26 | ||
|
|
846b315033 | ||
|
|
79043484f5 | ||
|
|
0fa69d7a50 | ||
|
|
81822f6f83 | ||
|
|
615f457d30 | ||
|
|
9f897a540d | ||
|
|
be0c2dbfaa | ||
|
|
367406daf1 | ||
|
|
05246bfb8b | ||
|
|
d510f34ad7 | ||
|
|
6933bd9551 | ||
|
|
d383d87b1f | ||
|
|
a8719691cd |
21
web/lib/opal/src/icons/arrow-up-down.tsx
Normal file
21
web/lib/opal/src/icons/arrow-up-down.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgArrowUpDown = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 13 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M11.75 2.97381L9.72145 0.945267C9.59128 0.81509 9.42066 0.750002 9.25005 0.750001M6.74999 2.97392L8.77865 0.94526C8.90881 0.815087 9.07943 0.75 9.25005 0.750001M9.25005 10.75V0.750001M5.74996 8.52613L3.72141 10.5547C3.59124 10.6849 3.42062 10.75 3.25001 10.75M0.75 8.52613L2.77861 10.5547C2.90877 10.6849 3.07939 10.75 3.25001 10.75M3.25001 0.75L3.25001 10.75"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgArrowUpDown;
|
||||
@@ -12,6 +12,7 @@ export { default as SvgArrowRight } from "@opal/icons/arrow-right";
|
||||
export { default as SvgArrowRightCircle } from "@opal/icons/arrow-right-circle";
|
||||
export { default as SvgArrowRightDot } from "@opal/icons/arrow-right-dot";
|
||||
export { default as SvgArrowUp } from "@opal/icons/arrow-up";
|
||||
export { default as SvgArrowUpDown } from "@opal/icons/arrow-up-down";
|
||||
export { default as SvgArrowUpDot } from "@opal/icons/arrow-up-dot";
|
||||
export { default as SvgArrowUpRight } from "@opal/icons/arrow-up-right";
|
||||
export { default as SvgArrowWallRight } from "@opal/icons/arrow-wall-right";
|
||||
@@ -76,6 +77,7 @@ export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
export { default as SvgImport } from "@opal/icons/import";
|
||||
export { default as SvgInfoSmall } from "@opal/icons/info-small";
|
||||
export { default as SvgKey } from "@opal/icons/key";
|
||||
export { default as SvgKeystroke } from "@opal/icons/keystroke";
|
||||
export { default as SvgLightbulbSimple } from "@opal/icons/lightbulb-simple";
|
||||
export { default as SvgLineChartUp } from "@opal/icons/line-chart-up";
|
||||
export { default as SvgLink } from "@opal/icons/link";
|
||||
|
||||
21
web/lib/opal/src/icons/keystroke.tsx
Normal file
21
web/lib/opal/src/icons/keystroke.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgKeystroke = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M12 4V9C12 9.55228 11.5523 10 11 10H5M5 10L6.5 8.5M5 10L6.5 11.5"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgKeystroke;
|
||||
@@ -40,6 +40,7 @@ export async function fetchChatSessions(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
11
web/src/app/css/divider.css
Normal file
11
web/src/app/css/divider.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* =============================================================================
|
||||
Divider Keyboard Navigation Overrides
|
||||
Disable hover effects when keyboard navigation is active
|
||||
============================================================================= */
|
||||
[data-keyboard-nav="true"] .group\/divider:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
[data-keyboard-nav="true"] .group\/divider[data-selected="true"] {
|
||||
background-color: var(--background-tint-02) !important;
|
||||
}
|
||||
@@ -1,34 +1,121 @@
|
||||
/* LineItem Button Variants */
|
||||
/* Hover styles are disabled when keyboard navigation is active (data-keyboard-nav on parent) */
|
||||
.line-item-button-main {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-main-emphasized {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
|
||||
/* Ensure selected wins over keyboard-nav hover override */
|
||||
[data-keyboard-nav="true"] &[data-selected="true"] {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-strikethrough {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-strikethrough-emphasized {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-danger {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-danger-emphasized {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
@apply bg-status-error-01;
|
||||
}
|
||||
|
||||
/* Ensure selected wins over keyboard-nav hover override */
|
||||
[data-keyboard-nav="true"] &[data-selected="true"] {
|
||||
@apply bg-status-error-01;
|
||||
}
|
||||
}
|
||||
|
||||
/* Action Variant - same background behavior as main */
|
||||
.line-item-button-action {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-action-emphasized {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
|
||||
/* Ensure selected wins over keyboard-nav hover override */
|
||||
[data-keyboard-nav="true"] &[data-selected="true"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
}
|
||||
|
||||
/* Muted Variant - subdued styling for less prominent items */
|
||||
.line-item-button-muted {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.line-item-button-muted-emphasized {
|
||||
@apply bg-transparent hover:bg-background-tint-02;
|
||||
|
||||
[data-keyboard-nav="true"] &:hover {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
&[data-selected="true"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
|
||||
/* Ensure selected wins over keyboard-nav hover override */
|
||||
[data-keyboard-nav="true"] &[data-selected="true"] {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
}
|
||||
|
||||
/* LineItem Text Variants */
|
||||
@@ -49,6 +136,28 @@
|
||||
color: var(--status-error-05) !important;
|
||||
}
|
||||
|
||||
.line-item-text-action {
|
||||
font-family: var(--font-hanken-grotesk);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0px;
|
||||
color: var(--text-04) !important;
|
||||
}
|
||||
|
||||
.line-item-text-muted {
|
||||
font-family: var(--font-hanken-grotesk);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0px;
|
||||
color: var(--text-03) !important;
|
||||
|
||||
.group\/LineItem[data-selected="true"] & {
|
||||
color: var(--text-03) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* LineItem Icon Variants */
|
||||
.line-item-icon-main {
|
||||
@apply stroke-text-03;
|
||||
@@ -65,3 +174,15 @@
|
||||
.line-item-icon-danger {
|
||||
@apply stroke-status-error-05;
|
||||
}
|
||||
|
||||
.line-item-icon-action {
|
||||
@apply stroke-text-03;
|
||||
}
|
||||
|
||||
.line-item-icon-muted {
|
||||
@apply stroke-text-02;
|
||||
|
||||
.group\/LineItem[data-selected="true"] & {
|
||||
@apply stroke-text-02;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "css/code.css";
|
||||
@import "css/color-swatch.css";
|
||||
@import "css/colors.css";
|
||||
@import "css/divider.css";
|
||||
@import "css/general-layouts.css";
|
||||
@import "css/inputs.css";
|
||||
@import "css/line-item.css";
|
||||
|
||||
@@ -20,7 +20,7 @@ const DEFAULT_AUTH_ERROR_MSG =
|
||||
const DEFAULT_ERROR_MSG = "An error occurred while fetching the data.";
|
||||
|
||||
export const errorHandlingFetcher = async <T>(url: string): Promise<T> => {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { credentials: "include" });
|
||||
|
||||
if (res.status === 403) {
|
||||
const redirect = new RedirectError(
|
||||
|
||||
263
web/src/refresh-components/Divider.tsx
Normal file
263
web/src/refresh-components/Divider.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgChevronRight, SvgChevronDown, SvgInfoSmall } from "@opal/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import Truncated from "./texts/Truncated";
|
||||
|
||||
export interface DividerProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Ref to the root element */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
/** Show title content instead of simple line */
|
||||
showTitle?: boolean;
|
||||
/** Title text */
|
||||
text?: string;
|
||||
/** Description text below title */
|
||||
description?: string;
|
||||
/** Show description */
|
||||
showDescription?: boolean;
|
||||
/** Enable foldable/collapsible behavior */
|
||||
foldable?: boolean;
|
||||
/** Controlled expanded state */
|
||||
expanded?: boolean;
|
||||
/** Callback when expanded changes */
|
||||
onClick?: () => void;
|
||||
/** Leading icon */
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
/** Show info icon */
|
||||
showInfo?: boolean;
|
||||
/** Info text on right side */
|
||||
infoText?: string;
|
||||
/** Apply highlighted (hover) state styling */
|
||||
isHighlighted?: boolean;
|
||||
/** Show horizontal divider lines (default: true) */
|
||||
dividerLine?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider Component
|
||||
*
|
||||
* A flexible divider component that supports two modes:
|
||||
* 1. Simple horizontal line divider
|
||||
* 2. Title divider with optional foldable/collapsible behavior, icons, and multiple interactive states
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Simple horizontal line divider
|
||||
* <Divider />
|
||||
*
|
||||
* // Title divider
|
||||
* <Divider showTitle text="Section Title" />
|
||||
*
|
||||
* // Title divider with icon
|
||||
* <Divider showTitle text="Settings" icon={SvgSettings} />
|
||||
*
|
||||
* // Foldable divider (collapsed)
|
||||
* <Divider showTitle text="Details" foldable expanded={false} onExpandedChange={setExpanded} />
|
||||
*
|
||||
* // Foldable divider (expanded)
|
||||
* <Divider showTitle text="Details" foldable expanded onExpandedChange={setExpanded} />
|
||||
*
|
||||
* // With info icon and text
|
||||
* <Divider showTitle text="Section" showInfo infoText="3 items" />
|
||||
*
|
||||
* // With description
|
||||
* <Divider showTitle text="Title" description="Optional description" showDescription />
|
||||
* ```
|
||||
*/
|
||||
export default function Divider({
|
||||
ref,
|
||||
showTitle,
|
||||
text = "Title",
|
||||
description,
|
||||
showDescription,
|
||||
foldable,
|
||||
expanded,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
showInfo,
|
||||
infoText,
|
||||
isHighlighted,
|
||||
dividerLine = true,
|
||||
className,
|
||||
...props
|
||||
}: DividerProps) {
|
||||
const handleClick = () => {
|
||||
if (foldable && onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Simple horizontal line divider
|
||||
if (!showTitle) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="separator"
|
||||
className={cn("w-full py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="h-px w-full bg-border-01" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Title divider with optional features
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role={foldable ? "button" : "separator"}
|
||||
tabIndex={foldable ? 0 : undefined}
|
||||
data-selected={isHighlighted ? "true" : undefined}
|
||||
onClick={foldable ? handleClick : undefined}
|
||||
onKeyDown={
|
||||
foldable
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full mt-1 py-0.5 rounded-08",
|
||||
foldable && "group/divider cursor-pointer",
|
||||
foldable && !expanded && "hover:bg-background-tint-02",
|
||||
foldable && !expanded && isHighlighted && "bg-background-tint-02",
|
||||
foldable &&
|
||||
expanded &&
|
||||
"bg-background-tint-01 hover:bg-background-tint-02",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Title line */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1",
|
||||
!dividerLine && (foldable ? "pl-1.5" : "px-2")
|
||||
)}
|
||||
>
|
||||
{/* Left divider line */}
|
||||
{dividerLine && (
|
||||
<div
|
||||
className={cn("h-px bg-border-01", foldable ? "w-1.5" : "w-2")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content container */}
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
{/* Icon container */}
|
||||
{Icon && (
|
||||
<div className="flex items-center justify-center size-5 p-0.5">
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-4 stroke-text-03",
|
||||
foldable && "group-hover/divider:stroke-text-04",
|
||||
foldable && expanded && "stroke-text-04",
|
||||
foldable && isHighlighted && "stroke-text-04"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title text */}
|
||||
<Text
|
||||
secondaryBody
|
||||
className={cn(
|
||||
"leading-4 truncate",
|
||||
!foldable && "text-text-03",
|
||||
foldable &&
|
||||
!expanded &&
|
||||
"text-text-03 group-hover/divider:text-text-04",
|
||||
foldable && expanded && "text-text-04",
|
||||
foldable && isHighlighted && "text-text-04"
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
|
||||
{/* Info icon */}
|
||||
{showInfo && (
|
||||
<div className="flex items-center justify-center size-5 p-0.5">
|
||||
<SvgInfoSmall
|
||||
className={cn(
|
||||
"size-3 stroke-text-03",
|
||||
foldable && "group-hover/divider:stroke-text-04",
|
||||
foldable && expanded && "stroke-text-04",
|
||||
foldable && isHighlighted && "stroke-text-04"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center divider line (flex-1 to fill remaining space) */}
|
||||
<div className={cn("flex-1", dividerLine && "h-px bg-border-01")} />
|
||||
|
||||
{/* Info text on right side */}
|
||||
{infoText && (
|
||||
<>
|
||||
<Text
|
||||
secondaryBody
|
||||
className={cn(
|
||||
"leading-4 px-0.5",
|
||||
!foldable && "text-text-03",
|
||||
foldable &&
|
||||
!expanded &&
|
||||
"text-text-03 group-hover/divider:text-text-04",
|
||||
foldable && expanded && "text-text-04",
|
||||
foldable && isHighlighted && "text-text-04"
|
||||
)}
|
||||
>
|
||||
{infoText}
|
||||
</Text>
|
||||
{/* Right divider line after info text */}
|
||||
{dividerLine && (
|
||||
<div
|
||||
className={cn("h-px bg-border-01", foldable ? "w-1.5" : "w-2")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Chevron button for foldable */}
|
||||
{foldable && (
|
||||
<div className="flex items-center justify-center size-6">
|
||||
{expanded ? (
|
||||
<SvgChevronDown
|
||||
className={cn(
|
||||
"size-4 stroke-text-03",
|
||||
"group-hover/divider:stroke-text-04",
|
||||
expanded && "stroke-text-04",
|
||||
isHighlighted && "stroke-text-04"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<SvgChevronRight
|
||||
className={cn(
|
||||
"size-4 stroke-text-03",
|
||||
"group-hover/divider:stroke-text-04",
|
||||
isHighlighted && "stroke-text-04"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description line */}
|
||||
{showDescription && description && (
|
||||
<div className="flex items-center py-1 pl-2">
|
||||
<Truncated secondaryBody text03>
|
||||
{description}
|
||||
</Truncated>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Throttle interval for scroll events (~60fps)
|
||||
const SCROLL_THROTTLE_MS = 16;
|
||||
|
||||
export interface ScrollIndicatorDivProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
// Mask/Shadow options
|
||||
@@ -31,8 +34,10 @@ export default function ScrollIndicatorDiv({
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showTopIndicator, setShowTopIndicator] = useState(false);
|
||||
const [showBottomIndicator, setShowBottomIndicator] = useState(false);
|
||||
const throttleTimeoutRef = useRef<number | null>(null);
|
||||
const isThrottledRef = useRef(false);
|
||||
|
||||
const updateScrollIndicators = () => {
|
||||
const updateScrollIndicators = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -47,7 +52,19 @@ export default function ScrollIndicatorDiv({
|
||||
setShowBottomIndicator(
|
||||
isScrollable && scrollTop < scrollHeight - clientHeight - 1
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Throttled scroll handler for better performance
|
||||
const handleScroll = useCallback(() => {
|
||||
if (isThrottledRef.current) return;
|
||||
|
||||
isThrottledRef.current = true;
|
||||
updateScrollIndicators();
|
||||
|
||||
throttleTimeoutRef.current = window.setTimeout(() => {
|
||||
isThrottledRef.current = false;
|
||||
}, SCROLL_THROTTLE_MS);
|
||||
}, [updateScrollIndicators]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -56,18 +73,21 @@ export default function ScrollIndicatorDiv({
|
||||
// Initial check
|
||||
updateScrollIndicators();
|
||||
|
||||
// Update on scroll
|
||||
container.addEventListener("scroll", updateScrollIndicators);
|
||||
// Update on scroll (throttled)
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
// Update on resize (in case content changes)
|
||||
const resizeObserver = new ResizeObserver(updateScrollIndicators);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", updateScrollIndicators);
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
if (throttleTimeoutRef.current) {
|
||||
clearTimeout(throttleTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [updateScrollIndicators, handleScroll]);
|
||||
|
||||
// Update when children change
|
||||
useEffect(() => {
|
||||
|
||||
91
web/src/refresh-components/buttons/EditableTag.tsx
Normal file
91
web/src/refresh-components/buttons/EditableTag.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgX } from "@opal/icons";
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
export interface EditableTagProps {
|
||||
label: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditableTag Component
|
||||
*
|
||||
* A removable tag component used for filters in CommandMenu and similar components.
|
||||
* Displays a label with optional icon and remove button.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage with remove
|
||||
* <EditableTag
|
||||
* label="Sessions"
|
||||
* onRemove={() => removeFilter("sessions")}
|
||||
* />
|
||||
*
|
||||
* // With icon
|
||||
* <EditableTag
|
||||
* label="Recent"
|
||||
* icon={SvgClock}
|
||||
* onRemove={() => removeFilter("recent")}
|
||||
* />
|
||||
*
|
||||
* // Clickable without remove
|
||||
* <EditableTag
|
||||
* label="All"
|
||||
* onClick={() => setFilter("all")}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export default function EditableTag({
|
||||
label,
|
||||
icon: Icon,
|
||||
onRemove,
|
||||
onClick,
|
||||
}: EditableTagProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-08",
|
||||
"bg-background-tint-02 hover:bg-background-tint-03",
|
||||
"transition-colors",
|
||||
onClick && "cursor-pointer"
|
||||
)}
|
||||
onClick={onClick}
|
||||
role={onClick ? "button" : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={
|
||||
onClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{Icon && <Icon className="size-3 stroke-text-03" />}
|
||||
<Text mainUiBody text04>
|
||||
{label}
|
||||
</Text>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="p-0.5 stroke-text-02 hover:stroke-text-03"
|
||||
aria-label={`Remove ${label} filter`}
|
||||
>
|
||||
<SvgX className="size-3 " />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,18 +20,30 @@ const buttonClassNames = {
|
||||
normal: "line-item-button-danger",
|
||||
emphasized: "line-item-button-danger-emphasized",
|
||||
},
|
||||
action: {
|
||||
normal: "line-item-button-action",
|
||||
emphasized: "line-item-button-action-emphasized",
|
||||
},
|
||||
muted: {
|
||||
normal: "line-item-button-muted",
|
||||
emphasized: "line-item-button-muted-emphasized",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const textClassNames = {
|
||||
main: "line-item-text-main",
|
||||
strikethrough: "line-item-text-strikethrough",
|
||||
danger: "line-item-text-danger",
|
||||
action: "line-item-text-action",
|
||||
muted: "line-item-text-muted",
|
||||
} as const;
|
||||
|
||||
const iconClassNames = {
|
||||
main: "line-item-icon-main",
|
||||
strikethrough: "line-item-icon-strikethrough",
|
||||
danger: "line-item-icon-danger",
|
||||
action: "line-item-icon-action",
|
||||
muted: "line-item-icon-muted",
|
||||
} as const;
|
||||
|
||||
export interface LineItemProps
|
||||
@@ -42,6 +54,8 @@ export interface LineItemProps
|
||||
// line-item variants
|
||||
strikethrough?: boolean;
|
||||
danger?: boolean;
|
||||
action?: boolean;
|
||||
muted?: boolean;
|
||||
|
||||
// modifier (makes the background more pronounced when selected).
|
||||
emphasized?: boolean;
|
||||
@@ -94,10 +108,15 @@ export interface LineItemProps
|
||||
* <LineItem icon={SvgArchive} strikethrough>
|
||||
* Archived Feature
|
||||
* </LineItem>
|
||||
*
|
||||
* // Muted variant (less prominent items)
|
||||
* <LineItem icon={SvgFolder} muted>
|
||||
* Secondary Item
|
||||
* </LineItem>
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* - Variants are mutually exclusive: only one of `strikethrough` or `danger` should be used
|
||||
* - Variants are mutually exclusive: only one of `strikethrough`, `danger`, `action`, or `muted` should be used
|
||||
* - The `selected` prop modifies text/icon colors for `main` and `danger` variants
|
||||
* - The `emphasized` prop adds background colors when combined with `selected`
|
||||
* - The component automatically adds a `data-selected="true"` attribute for custom styling
|
||||
@@ -106,6 +125,8 @@ export default function LineItem({
|
||||
selected,
|
||||
strikethrough,
|
||||
danger,
|
||||
action,
|
||||
muted,
|
||||
emphasized,
|
||||
icon: Icon,
|
||||
description,
|
||||
@@ -115,8 +136,16 @@ export default function LineItem({
|
||||
ref,
|
||||
...props
|
||||
}: LineItemProps) {
|
||||
// Determine variant (mutually exclusive, with priority order)
|
||||
const variant = strikethrough ? "strikethrough" : danger ? "danger" : "main";
|
||||
// Determine variant (mutually exclusive, with priority order: strikethrough > danger > action > muted > main)
|
||||
const variant = strikethrough
|
||||
? "strikethrough"
|
||||
: danger
|
||||
? "danger"
|
||||
: action
|
||||
? "action"
|
||||
: muted
|
||||
? "muted"
|
||||
: "main";
|
||||
|
||||
const emphasisKey = emphasized ? "emphasized" : "normal";
|
||||
|
||||
|
||||
747
web/src/refresh-components/commandmenu/CommandMenu.tsx
Normal file
747
web/src/refresh-components/commandmenu/CommandMenu.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import EditableTag from "@/refresh-components/buttons/EditableTag";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { SvgChevronRight, SvgSearch, SvgX } from "@opal/icons";
|
||||
import type {
|
||||
CommandMenuProps,
|
||||
CommandMenuContentProps,
|
||||
CommandMenuHeaderProps,
|
||||
CommandMenuListProps,
|
||||
CommandMenuFilterProps,
|
||||
CommandMenuItemProps,
|
||||
CommandMenuActionProps,
|
||||
CommandMenuFooterProps,
|
||||
CommandMenuFooterActionProps,
|
||||
CommandMenuContextValue,
|
||||
} from "./types";
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const CommandMenuContext = createContext<CommandMenuContextValue | null>(null);
|
||||
|
||||
function useCommandMenuContext() {
|
||||
const context = useContext(CommandMenuContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"CommandMenu compound components must be used within CommandMenu"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Root
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Gets ordered items by querying DOM for data-command-item elements.
|
||||
* Safe to call in event handlers (after DOM is committed).
|
||||
*/
|
||||
function getOrderedItems(): string[] {
|
||||
const container = document.querySelector("[data-command-menu-list]");
|
||||
if (!container) return [];
|
||||
const elements = container.querySelectorAll("[data-command-item]");
|
||||
return Array.from(elements)
|
||||
.map((el) => el.getAttribute("data-command-item"))
|
||||
.filter((v): v is string => v !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* CommandMenu Root Component
|
||||
*
|
||||
* Wrapper around Radix Dialog.Root for managing command menu state.
|
||||
* Centralizes all keyboard/selection logic - items only render and report mouse events.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <CommandMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
* <CommandMenu.Content>
|
||||
* <CommandMenu.Header placeholder="Search..." />
|
||||
* <CommandMenu.List>
|
||||
* <CommandMenu.Item value="1">Item 1</CommandMenu.Item>
|
||||
* </CommandMenu.List>
|
||||
* <CommandMenu.Footer />
|
||||
* </CommandMenu.Content>
|
||||
* </CommandMenu>
|
||||
* ```
|
||||
*/
|
||||
function CommandMenuRoot({ open, onOpenChange, children }: CommandMenuProps) {
|
||||
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [isKeyboardNav, setIsKeyboardNav] = React.useState(false);
|
||||
|
||||
// Centralized callback registry - items register their onSelect callback and type
|
||||
const itemCallbacks = useRef<
|
||||
Map<string, { callback: () => void; type: "filter" | "item" | "action" }>
|
||||
>(new Map());
|
||||
|
||||
// Reset state when menu closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setHighlightedValue(null);
|
||||
setIsKeyboardNav(false);
|
||||
itemCallbacks.current.clear();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Registration functions (items call on mount)
|
||||
const registerItem = useCallback(
|
||||
(
|
||||
value: string,
|
||||
onSelect: () => void,
|
||||
type: "filter" | "item" | "action" = "item"
|
||||
) => {
|
||||
if (
|
||||
process.env.NODE_ENV === "development" &&
|
||||
itemCallbacks.current.has(value)
|
||||
) {
|
||||
console.warn(
|
||||
`[CommandMenu] Duplicate value "${value}" registered. ` +
|
||||
`Values must be unique across all Filter, Item, and Action components.`
|
||||
);
|
||||
}
|
||||
itemCallbacks.current.set(value, { callback: onSelect, type });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unregisterItem = useCallback((value: string) => {
|
||||
itemCallbacks.current.delete(value);
|
||||
}, []);
|
||||
|
||||
// Shared mouse handlers (items call on events)
|
||||
const onItemMouseEnter = useCallback(
|
||||
(value: string) => {
|
||||
if (!isKeyboardNav) {
|
||||
setHighlightedValue(value);
|
||||
}
|
||||
},
|
||||
[isKeyboardNav]
|
||||
);
|
||||
|
||||
const onItemMouseMove = useCallback(
|
||||
(value: string) => {
|
||||
if (isKeyboardNav) {
|
||||
setIsKeyboardNav(false);
|
||||
}
|
||||
if (highlightedValue !== value) {
|
||||
setHighlightedValue(value);
|
||||
}
|
||||
},
|
||||
[isKeyboardNav, highlightedValue]
|
||||
);
|
||||
|
||||
const onItemClick = useCallback(
|
||||
(value: string) => {
|
||||
const entry = itemCallbacks.current.get(value);
|
||||
entry?.callback();
|
||||
if (entry?.type !== "filter") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const onListMouseLeave = useCallback(() => {
|
||||
if (!isKeyboardNav) {
|
||||
setHighlightedValue(null);
|
||||
}
|
||||
}, [isKeyboardNav]);
|
||||
|
||||
// Compute the type of the currently highlighted item
|
||||
const highlightedItemType = useMemo(() => {
|
||||
if (!highlightedValue) return null;
|
||||
return itemCallbacks.current.get(highlightedValue)?.type ?? null;
|
||||
}, [highlightedValue]);
|
||||
|
||||
// Keyboard handler - centralized for all keys including Enter
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown": {
|
||||
e.preventDefault();
|
||||
const wasKeyboardNav = isKeyboardNav;
|
||||
setIsKeyboardNav(true);
|
||||
const items = getOrderedItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
// If transitioning from mouse to keyboard mode, start from first item
|
||||
if (!wasKeyboardNav) {
|
||||
const firstItem = items[0];
|
||||
if (firstItem !== undefined) {
|
||||
setHighlightedValue(firstItem);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Continue normal keyboard navigation
|
||||
const currentIndex = highlightedValue
|
||||
? items.indexOf(highlightedValue)
|
||||
: -1;
|
||||
const nextIndex =
|
||||
currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
const nextItem = items[nextIndex];
|
||||
if (nextItem !== undefined) {
|
||||
setHighlightedValue(nextItem);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
e.preventDefault();
|
||||
const wasKeyboardNav = isKeyboardNav;
|
||||
setIsKeyboardNav(true);
|
||||
const items = getOrderedItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
// If transitioning from mouse to keyboard mode, start from first item
|
||||
if (!wasKeyboardNav) {
|
||||
const firstItem = items[0];
|
||||
if (firstItem !== undefined) {
|
||||
setHighlightedValue(firstItem);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Continue normal keyboard navigation
|
||||
const currentIndex = highlightedValue
|
||||
? items.indexOf(highlightedValue)
|
||||
: 0;
|
||||
const prevIndex =
|
||||
currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
const prevItem = items[prevIndex];
|
||||
if (prevItem !== undefined) {
|
||||
setHighlightedValue(prevItem);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Enter": {
|
||||
e.preventDefault();
|
||||
if (highlightedValue) {
|
||||
const entry = itemCallbacks.current.get(highlightedValue);
|
||||
entry?.callback();
|
||||
if (entry?.type !== "filter") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
e.preventDefault();
|
||||
onOpenChange(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[highlightedValue, isKeyboardNav, onOpenChange]
|
||||
);
|
||||
|
||||
// Scroll highlighted item into view on keyboard nav
|
||||
// Uses manual scroll calculation instead of scrollIntoView to only scroll
|
||||
// the list container, not the modal or other ancestors
|
||||
useEffect(() => {
|
||||
if (isKeyboardNav && highlightedValue) {
|
||||
const container = document.querySelector("[data-command-menu-list]");
|
||||
// Use safe attribute matching instead of direct selector interpolation
|
||||
// to prevent CSS selector injection
|
||||
const el = Array.from(
|
||||
container?.querySelectorAll("[data-command-item]") ?? []
|
||||
).find((e) => e.getAttribute("data-command-item") === highlightedValue);
|
||||
|
||||
if (container && el instanceof HTMLElement) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
|
||||
const scrollMargin = 60;
|
||||
if (elRect.top < containerRect.top + scrollMargin) {
|
||||
container.scrollTop -= containerRect.top + scrollMargin - elRect.top;
|
||||
} else if (elRect.bottom > containerRect.bottom) {
|
||||
container.scrollTop += elRect.bottom - containerRect.bottom;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [highlightedValue, isKeyboardNav]);
|
||||
|
||||
const contextValue = useMemo<CommandMenuContextValue>(
|
||||
() => ({
|
||||
highlightedValue,
|
||||
highlightedItemType,
|
||||
isKeyboardNav,
|
||||
registerItem,
|
||||
unregisterItem,
|
||||
onItemMouseEnter,
|
||||
onItemMouseMove,
|
||||
onItemClick,
|
||||
onListMouseLeave,
|
||||
handleKeyDown,
|
||||
}),
|
||||
[
|
||||
highlightedValue,
|
||||
highlightedItemType,
|
||||
isKeyboardNav,
|
||||
registerItem,
|
||||
unregisterItem,
|
||||
onItemMouseEnter,
|
||||
onItemMouseMove,
|
||||
onItemClick,
|
||||
onListMouseLeave,
|
||||
handleKeyDown,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandMenuContext.Provider value={contextValue}>
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</CommandMenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Content
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Content Component
|
||||
*
|
||||
* Modal container with overlay, sizing, and animations.
|
||||
* Keyboard handling is centralized in Root and accessed via context.
|
||||
*/
|
||||
const CommandMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
CommandMenuContentProps
|
||||
>(({ children }, ref) => {
|
||||
const { handleKeyDown } = useCommandMenuContext();
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Overlay - hidden from assistive technology */}
|
||||
<DialogPrimitive.Overlay
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"fixed inset-0 z-modal-overlay bg-mask-03 backdrop-blur-03 pointer-events-none",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0"
|
||||
)}
|
||||
/>
|
||||
{/* Content */}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"z-modal",
|
||||
"bg-background-tint-00 border rounded-16 shadow-2xl outline-none",
|
||||
"flex flex-col overflow-hidden",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:slide-in-from-top-1/2 data-[state=closed]:slide-out-to-top-1/2",
|
||||
"duration-200",
|
||||
"w-[32rem]",
|
||||
"min-h-[15rem] max-h-[40rem]"
|
||||
)}
|
||||
>
|
||||
<VisuallyHidden.Root asChild>
|
||||
<DialogPrimitive.Title>Command Menu</DialogPrimitive.Title>
|
||||
</VisuallyHidden.Root>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
CommandMenuContent.displayName = "CommandMenuContent";
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Header
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Header Component
|
||||
*
|
||||
* Contains filter tags and search input.
|
||||
* Arrow keys preventDefault at input level (to stop cursor movement) then bubble to Content.
|
||||
*/
|
||||
function CommandMenuHeader({
|
||||
placeholder = "Search...",
|
||||
filters = [],
|
||||
value = "",
|
||||
onValueChange,
|
||||
onFilterRemove,
|
||||
onClose,
|
||||
}: CommandMenuHeaderProps) {
|
||||
// Prevent default for arrow/enter keys so they don't move cursor or submit forms
|
||||
// The actual handling happens in Root's centralized handler via event bubbling
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0">
|
||||
{/* Top row: Search icon, filters, close button */}
|
||||
<div className="px-3 pt-3 flex flex-row justify-between items-center">
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
gap={0.5}
|
||||
width="fit"
|
||||
>
|
||||
{/* Standalone search icon */}
|
||||
<SvgSearch className="w-6 h-6 stroke-text-04" />
|
||||
{filters.map((filter) => (
|
||||
<EditableTag
|
||||
key={filter.id}
|
||||
label={filter.label}
|
||||
icon={filter.icon}
|
||||
onRemove={
|
||||
onFilterRemove ? () => onFilterRemove(filter.id) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
{onClose && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<IconButton
|
||||
icon={SvgX}
|
||||
internal
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
/>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
{/* Search input - arrow/enter keys bubble up to Content for centralized handling */}
|
||||
<div className="px-2 pb-2 pt-0.5">
|
||||
<InputTypeIn
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onValueChange?.(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
autoFocus
|
||||
className="w-full !bg-transparent !border-transparent [&:is(:hover,:active,:focus,:focus-within)]:!bg-background-neutral-00 [&:is(:hover,:active,:focus,:focus-within)]:!border-border-01 [&:is(:focus,:focus-within)]:!shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu List
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu List Component
|
||||
*
|
||||
* Scrollable container for menu items with scroll shadow indicators.
|
||||
* Uses ScrollIndicatorDiv for automatic scroll shadows.
|
||||
*/
|
||||
function CommandMenuList({ children, emptyMessage }: CommandMenuListProps) {
|
||||
const { isKeyboardNav, onListMouseLeave } = useCommandMenuContext();
|
||||
const childCount = React.Children.count(children);
|
||||
|
||||
if (childCount === 0 && emptyMessage) {
|
||||
return (
|
||||
<div
|
||||
className="bg-background-tint-01 p-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Text secondaryBody text03>
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollIndicatorDiv
|
||||
role="listbox"
|
||||
aria-label="Command menu options"
|
||||
className="p-1 gap-0 max-h-[60vh] bg-background-tint-01"
|
||||
backgroundColor="var(--background-tint-01)"
|
||||
data-command-menu-list
|
||||
data-keyboard-nav={isKeyboardNav ? "true" : undefined}
|
||||
variant="shadow"
|
||||
onMouseLeave={onListMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</ScrollIndicatorDiv>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Filter
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Filter Component
|
||||
*
|
||||
* When `isApplied` is true, renders as a non-interactive group label.
|
||||
* Otherwise, renders as a selectable filter with a chevron indicator.
|
||||
* Dumb component - registers callback on mount, renders based on context state.
|
||||
*/
|
||||
function CommandMenuFilter({
|
||||
value,
|
||||
children,
|
||||
icon,
|
||||
isApplied,
|
||||
onSelect,
|
||||
}: CommandMenuFilterProps) {
|
||||
const {
|
||||
highlightedValue,
|
||||
registerItem,
|
||||
unregisterItem,
|
||||
onItemMouseEnter,
|
||||
onItemMouseMove,
|
||||
onItemClick,
|
||||
} = useCommandMenuContext();
|
||||
|
||||
// Register callback on mount - NO keyboard listener needed
|
||||
useEffect(() => {
|
||||
if (!isApplied && onSelect) {
|
||||
registerItem(value, () => onSelect(), "filter");
|
||||
return () => unregisterItem(value);
|
||||
}
|
||||
}, [value, isApplied, onSelect, registerItem, unregisterItem]);
|
||||
|
||||
// When filter is applied, show as group label (non-interactive)
|
||||
if (isApplied) {
|
||||
return (
|
||||
<Divider
|
||||
showTitle
|
||||
text={children as string}
|
||||
icon={icon}
|
||||
dividerLine={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isHighlighted = value === highlightedValue;
|
||||
|
||||
// Selectable filter - uses LineItem, delegates all events to context
|
||||
return (
|
||||
<div data-command-item={value} role="option" aria-selected={isHighlighted}>
|
||||
<Divider
|
||||
showTitle
|
||||
text={children as string}
|
||||
icon={icon}
|
||||
foldable
|
||||
isHighlighted={isHighlighted}
|
||||
onClick={() => onItemClick(value)}
|
||||
onMouseEnter={() => onItemMouseEnter(value)}
|
||||
onMouseMove={() => onItemMouseMove(value)}
|
||||
dividerLine={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Item
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Item Component
|
||||
*
|
||||
* Dumb component - registers callback on mount, renders based on context state.
|
||||
* Use rightContent for timestamps, badges, etc.
|
||||
*/
|
||||
function CommandMenuItem({
|
||||
value,
|
||||
icon,
|
||||
rightContent,
|
||||
onSelect,
|
||||
children,
|
||||
}: CommandMenuItemProps) {
|
||||
const {
|
||||
highlightedValue,
|
||||
registerItem,
|
||||
unregisterItem,
|
||||
onItemMouseEnter,
|
||||
onItemMouseMove,
|
||||
onItemClick,
|
||||
} = useCommandMenuContext();
|
||||
|
||||
// Register callback on mount - NO keyboard listener needed
|
||||
useEffect(() => {
|
||||
registerItem(value, () => onSelect?.(value));
|
||||
return () => unregisterItem(value);
|
||||
}, [value, onSelect, registerItem, unregisterItem]);
|
||||
|
||||
const isHighlighted = value === highlightedValue;
|
||||
|
||||
// Resolve rightContent - supports both static ReactNode and render function
|
||||
const resolvedRightContent =
|
||||
typeof rightContent === "function"
|
||||
? rightContent({ isHighlighted })
|
||||
: rightContent;
|
||||
|
||||
return (
|
||||
<div data-command-item={value} role="option" aria-selected={isHighlighted}>
|
||||
<LineItem
|
||||
muted
|
||||
icon={icon}
|
||||
rightChildren={resolvedRightContent}
|
||||
emphasized={isHighlighted}
|
||||
selected={isHighlighted}
|
||||
onClick={() => onItemClick(value)}
|
||||
onMouseEnter={() => onItemMouseEnter(value)}
|
||||
onMouseMove={() => onItemMouseMove(value)}
|
||||
>
|
||||
{children}
|
||||
</LineItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Action
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Action Component
|
||||
*
|
||||
* Dumb component - registers callback on mount, renders based on context state.
|
||||
* Uses LineItem with action variant for visual distinction.
|
||||
*/
|
||||
function CommandMenuAction({
|
||||
value,
|
||||
icon,
|
||||
shortcut,
|
||||
onSelect,
|
||||
children,
|
||||
}: CommandMenuActionProps) {
|
||||
const {
|
||||
highlightedValue,
|
||||
registerItem,
|
||||
unregisterItem,
|
||||
onItemMouseEnter,
|
||||
onItemMouseMove,
|
||||
onItemClick,
|
||||
} = useCommandMenuContext();
|
||||
|
||||
// Register callback on mount - NO keyboard listener needed
|
||||
useEffect(() => {
|
||||
registerItem(value, () => onSelect?.(value), "action");
|
||||
return () => unregisterItem(value);
|
||||
}, [value, onSelect, registerItem, unregisterItem]);
|
||||
|
||||
const isHighlighted = value === highlightedValue;
|
||||
|
||||
return (
|
||||
<div data-command-item={value} role="option" aria-selected={isHighlighted}>
|
||||
<LineItem
|
||||
action
|
||||
icon={icon}
|
||||
rightChildren={
|
||||
shortcut ? (
|
||||
<Text figureKeystroke text02>
|
||||
{shortcut}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
emphasized={isHighlighted}
|
||||
selected={isHighlighted}
|
||||
onClick={() => onItemClick(value)}
|
||||
onMouseEnter={() => onItemMouseEnter(value)}
|
||||
onMouseMove={() => onItemMouseMove(value)}
|
||||
>
|
||||
{children}
|
||||
</LineItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Footer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Footer Component
|
||||
*
|
||||
* Footer section with keyboard hint actions.
|
||||
*/
|
||||
function CommandMenuFooter({ leftActions }: CommandMenuFooterProps) {
|
||||
return (
|
||||
<div className="flex-shrink-0">
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
gap={1}
|
||||
padding={0.75}
|
||||
>
|
||||
{leftActions}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CommandMenu Footer Action
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* CommandMenu Footer Action Component
|
||||
*
|
||||
* Display-only visual hint showing a keyboard shortcut.
|
||||
*/
|
||||
function CommandMenuFooterAction({
|
||||
icon: Icon,
|
||||
label,
|
||||
}: CommandMenuFooterActionProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1" aria-label={label}>
|
||||
<Icon
|
||||
className="w-[0.875rem] h-[0.875rem] stroke-text-02"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Text mainUiBody text03>
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export Compound Component
|
||||
// =============================================================================
|
||||
|
||||
export { useCommandMenuContext };
|
||||
|
||||
export default Object.assign(CommandMenuRoot, {
|
||||
Content: CommandMenuContent,
|
||||
Header: CommandMenuHeader,
|
||||
List: CommandMenuList,
|
||||
Filter: CommandMenuFilter,
|
||||
Item: CommandMenuItem,
|
||||
Action: CommandMenuAction,
|
||||
Footer: CommandMenuFooter,
|
||||
FooterAction: CommandMenuFooterAction,
|
||||
});
|
||||
143
web/src/refresh-components/commandmenu/types.ts
Normal file
143
web/src/refresh-components/commandmenu/types.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
// =============================================================================
|
||||
// Filter Object (for header display)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Filter object for CommandMenu header
|
||||
*/
|
||||
export interface CommandMenuFilter {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu root component
|
||||
*/
|
||||
export interface CommandMenuProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu content (modal container)
|
||||
*/
|
||||
export interface CommandMenuContentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu header with search and filters
|
||||
*/
|
||||
export interface CommandMenuHeaderProps {
|
||||
placeholder?: string;
|
||||
filters?: CommandMenuFilter[];
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
onFilterRemove?: (filterId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu list container
|
||||
*/
|
||||
export interface CommandMenuListProps {
|
||||
children: React.ReactNode;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu filter (selectable or as applied group label)
|
||||
*/
|
||||
export interface CommandMenuFilterProps {
|
||||
/**
|
||||
* Unique identifier for this item within the CommandMenu.
|
||||
* Must be unique across all Filter, Item, and Action components.
|
||||
* Used for keyboard navigation, selection callbacks, and highlight state.
|
||||
*/
|
||||
value: string;
|
||||
children: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
isApplied?: boolean; // When true, renders as non-interactive group label
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu item
|
||||
*/
|
||||
export interface CommandMenuItemProps {
|
||||
/**
|
||||
* Unique identifier for this item within the CommandMenu.
|
||||
* Must be unique across all Filter, Item, and Action components.
|
||||
* Used for keyboard navigation, selection callbacks, and highlight state.
|
||||
*/
|
||||
value: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
rightContent?:
|
||||
| React.ReactNode
|
||||
| ((params: { isHighlighted: boolean }) => React.ReactNode); // For timestamps, badges, etc.
|
||||
onSelect?: (value: string) => void;
|
||||
children: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu action (quick actions with keyboard shortcuts)
|
||||
*/
|
||||
export interface CommandMenuActionProps {
|
||||
/**
|
||||
* Unique identifier for this item within the CommandMenu.
|
||||
* Must be unique across all Filter, Item, and Action components.
|
||||
* Used for keyboard navigation, selection callbacks, and highlight state.
|
||||
*/
|
||||
value: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
shortcut?: string; // Keyboard shortcut like "⌘N", "⌘P"
|
||||
onSelect?: (value: string) => void;
|
||||
children: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu footer
|
||||
*/
|
||||
export interface CommandMenuFooterProps {
|
||||
leftActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for CommandMenu footer action hint
|
||||
*/
|
||||
export interface CommandMenuFooterActionProps {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context value for CommandMenu keyboard navigation
|
||||
* Uses centralized control with callback registry - items are "dumb" renderers
|
||||
*/
|
||||
export interface CommandMenuContextValue {
|
||||
// State
|
||||
highlightedValue: string | null;
|
||||
highlightedItemType: "filter" | "item" | "action" | null;
|
||||
isKeyboardNav: boolean;
|
||||
|
||||
// Registration (items call on mount with their callback)
|
||||
registerItem: (
|
||||
value: string,
|
||||
onSelect: () => void,
|
||||
type?: "filter" | "item" | "action"
|
||||
) => void;
|
||||
unregisterItem: (value: string) => void;
|
||||
|
||||
// Mouse interaction (items call on events - centralized in root)
|
||||
onItemMouseEnter: (value: string) => void;
|
||||
onItemMouseMove: (value: string) => void;
|
||||
onItemClick: (value: string) => void;
|
||||
onListMouseLeave: () => void;
|
||||
|
||||
// Keyboard handler (Content attaches this to DialogPrimitive.Content)
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
@@ -61,9 +61,11 @@ import {
|
||||
SvgFolderPlus,
|
||||
SvgMoreHorizontal,
|
||||
SvgOnyxOctagon,
|
||||
SvgSearchMenu,
|
||||
SvgSettings,
|
||||
} from "@opal/icons";
|
||||
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
|
||||
import ChatSearchCommandMenu from "@/sections/sidebar/ChatSearchCommandMenu";
|
||||
|
||||
// Visible-agents = pinned-agents + current-agent (if current-agent not in pinned-agents)
|
||||
// OR Visible-agents = pinned-agents (if current-agent in pinned-agents)
|
||||
@@ -373,6 +375,18 @@ const MemoizedAppSidebarInner = memo(
|
||||
</div>
|
||||
);
|
||||
}, [folded, activeSidebarTab, combinedSettings, currentAgent]);
|
||||
const searchChatsButton = useMemo(
|
||||
() => (
|
||||
<ChatSearchCommandMenu
|
||||
trigger={
|
||||
<SidebarTab leftIcon={SvgSearchMenu} folded={folded}>
|
||||
Search chats
|
||||
</SidebarTab>
|
||||
}
|
||||
/>
|
||||
),
|
||||
[folded]
|
||||
);
|
||||
const moreAgentsButton = useMemo(
|
||||
() => (
|
||||
<div data-testid="AppSidebar/more-agents">
|
||||
@@ -469,7 +483,12 @@ const MemoizedAppSidebarInner = memo(
|
||||
<SidebarBody
|
||||
scrollKey="app-sidebar"
|
||||
footer={settingsButton}
|
||||
actionButton={newSessionButton}
|
||||
actionButton={
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{newSessionButton}
|
||||
{searchChatsButton}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* When folded, show icons immediately without waiting for data */}
|
||||
{folded ? (
|
||||
|
||||
330
web/src/sections/sidebar/ChatSearchCommandMenu.tsx
Normal file
330
web/src/sections/sidebar/ChatSearchCommandMenu.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
import CommandMenu, {
|
||||
useCommandMenuContext,
|
||||
} from "@/refresh-components/commandmenu/CommandMenu";
|
||||
import { useProjects } from "@/lib/hooks/useProjects";
|
||||
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import CreateProjectModal from "@/components/modals/CreateProjectModal";
|
||||
import { formatDisplayTime } from "@/sections/sidebar/chatSearchUtils";
|
||||
import { useSettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { useCurrentAgent } from "@/hooks/useAgents";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
useChatSearchOptimistic,
|
||||
FilterableChat,
|
||||
} from "./useChatSearchOptimistic";
|
||||
import {
|
||||
SvgEditBig,
|
||||
SvgFolder,
|
||||
SvgFolderPlus,
|
||||
SvgBubbleText,
|
||||
SvgArrowUpDown,
|
||||
SvgKeystroke,
|
||||
} from "@opal/icons";
|
||||
|
||||
/**
|
||||
* Dynamic footer that shows contextual action labels based on highlighted item type
|
||||
*/
|
||||
function DynamicFooter() {
|
||||
const { highlightedItemType } = useCommandMenuContext();
|
||||
|
||||
// "Show all" for filters, "Open" for everything else (items, actions, or no highlight)
|
||||
const actionLabel = highlightedItemType === "filter" ? "Show all" : "Open";
|
||||
|
||||
return (
|
||||
<CommandMenu.Footer
|
||||
leftActions={
|
||||
<>
|
||||
<CommandMenu.FooterAction icon={SvgArrowUpDown} label="Select" />
|
||||
<CommandMenu.FooterAction icon={SvgKeystroke} label={actionLabel} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatSearchCommandMenuProps {
|
||||
trigger: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FilterableProject {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string | null;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function ChatSearchCommandMenu({
|
||||
trigger,
|
||||
}: ChatSearchCommandMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<
|
||||
"all" | "chats" | "projects"
|
||||
>("all");
|
||||
const router = useRouter();
|
||||
|
||||
// Data hooks
|
||||
const { projects } = useProjects();
|
||||
const combinedSettings = useSettingsContext();
|
||||
const currentAgent = useCurrentAgent();
|
||||
const createProjectModal = useCreateModal();
|
||||
|
||||
// Constants for preview limits
|
||||
const PREVIEW_CHATS_LIMIT = 4;
|
||||
const PREVIEW_PROJECTS_LIMIT = 2;
|
||||
|
||||
// Determine if we should enable optimistic search (when searching or viewing chats filter)
|
||||
const shouldUseOptimisticSearch =
|
||||
searchValue.trim().length > 0 || activeFilter === "chats";
|
||||
|
||||
// Use optimistic search hook for chat sessions (includes fallback from useChatSessions + useProjects)
|
||||
const {
|
||||
results: filteredChats,
|
||||
isSearching,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
sentinelRef,
|
||||
} = useChatSearchOptimistic({
|
||||
searchQuery: searchValue,
|
||||
enabled: shouldUseOptimisticSearch,
|
||||
});
|
||||
|
||||
// Transform and filter projects (sorted by latest first)
|
||||
const filteredProjects = useMemo<FilterableProject[]>(() => {
|
||||
const projectList = projects
|
||||
.map((project) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
description: project.description,
|
||||
time: project.created_at,
|
||||
}))
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||
|
||||
if (!searchValue.trim()) return projectList;
|
||||
|
||||
const term = searchValue.toLowerCase();
|
||||
return projectList.filter(
|
||||
(project) =>
|
||||
project.label.toLowerCase().includes(term) ||
|
||||
project.description?.toLowerCase().includes(term)
|
||||
);
|
||||
}, [projects, searchValue]);
|
||||
|
||||
// Compute displayed items based on filter state
|
||||
const displayedChats = useMemo(() => {
|
||||
if (activeFilter === "all" && !searchValue.trim()) {
|
||||
return filteredChats.slice(0, PREVIEW_CHATS_LIMIT);
|
||||
}
|
||||
return filteredChats;
|
||||
}, [filteredChats, activeFilter, searchValue]);
|
||||
|
||||
const displayedProjects = useMemo(() => {
|
||||
if (activeFilter === "all" && !searchValue.trim()) {
|
||||
return filteredProjects.slice(0, PREVIEW_PROJECTS_LIMIT);
|
||||
}
|
||||
return filteredProjects;
|
||||
}, [filteredProjects, activeFilter, searchValue]);
|
||||
|
||||
// Header filters for showing active filter as a chip
|
||||
const headerFilters = useMemo(() => {
|
||||
if (activeFilter === "chats") {
|
||||
return [{ id: "chats", label: "Sessions" }];
|
||||
}
|
||||
if (activeFilter === "projects") {
|
||||
return [{ id: "projects", label: "Projects" }];
|
||||
}
|
||||
return [];
|
||||
}, [activeFilter]);
|
||||
|
||||
const handleFilterRemove = useCallback(() => {
|
||||
setActiveFilter("all");
|
||||
}, []);
|
||||
|
||||
// Navigation handlers
|
||||
const handleNewSession = useCallback(() => {
|
||||
const href =
|
||||
combinedSettings?.settings?.disable_default_assistant && currentAgent
|
||||
? `/chat?assistantId=${currentAgent.id}`
|
||||
: "/chat";
|
||||
router.push(href as Route);
|
||||
setOpen(false);
|
||||
}, [router, combinedSettings, currentAgent]);
|
||||
|
||||
const handleChatSelect = useCallback(
|
||||
(chatId: string) => {
|
||||
router.push(`/chat?chatId=${chatId}` as Route);
|
||||
setOpen(false);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
(projectId: number) => {
|
||||
router.push(`/chat?projectId=${projectId}` as Route);
|
||||
setOpen(false);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleNewProject = useCallback(() => {
|
||||
setOpen(false);
|
||||
createProjectModal.toggle(true);
|
||||
}, [createProjectModal]);
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
if (!newOpen) {
|
||||
setSearchValue("");
|
||||
setActiveFilter("all");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasSearchValue = searchValue.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setOpen(true)}>{trigger}</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<CommandMenu.Content>
|
||||
<CommandMenu.Header
|
||||
placeholder="Search chat sessions, projects..."
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
filters={headerFilters}
|
||||
onFilterRemove={handleFilterRemove}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
<CommandMenu.List
|
||||
emptyMessage={
|
||||
hasSearchValue ? "No results found" : "No chats or projects yet"
|
||||
}
|
||||
>
|
||||
{/* New Session action - always shown when no search and no filter */}
|
||||
{!hasSearchValue && activeFilter === "all" && (
|
||||
<CommandMenu.Action
|
||||
value="new-session"
|
||||
icon={SvgEditBig}
|
||||
onSelect={handleNewSession}
|
||||
>
|
||||
New Session
|
||||
</CommandMenu.Action>
|
||||
)}
|
||||
|
||||
{/* Recent Sessions section - show if filter is 'all' or 'chats' */}
|
||||
{(activeFilter === "all" || activeFilter === "chats") &&
|
||||
displayedChats.length > 0 && (
|
||||
<>
|
||||
{(activeFilter === "all" || activeFilter === "chats") &&
|
||||
searchValue.trim().length === 0 && (
|
||||
<CommandMenu.Filter
|
||||
value="recent-sessions"
|
||||
onSelect={() => setActiveFilter("chats")}
|
||||
isApplied={activeFilter === "chats"}
|
||||
>
|
||||
{activeFilter === "chats"
|
||||
? "Recent"
|
||||
: "Recent Sessions"}
|
||||
</CommandMenu.Filter>
|
||||
)}
|
||||
{displayedChats.map((chat) => (
|
||||
<CommandMenu.Item
|
||||
key={chat.id}
|
||||
value={`chat-${chat.id}`}
|
||||
icon={SvgBubbleText}
|
||||
rightContent={({ isHighlighted }) =>
|
||||
isHighlighted ? (
|
||||
<Text figureKeystroke text02>
|
||||
↵
|
||||
</Text>
|
||||
) : (
|
||||
<Text secondaryBody text03>
|
||||
{formatDisplayTime(chat.time)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
onSelect={() => handleChatSelect(chat.id)}
|
||||
>
|
||||
{chat.label}
|
||||
</CommandMenu.Item>
|
||||
))}
|
||||
{/* Infinite scroll sentinel and loading indicator for chats */}
|
||||
{activeFilter === "chats" && hasMore && (
|
||||
<div ref={sentinelRef} className="h-1" aria-hidden="true" />
|
||||
)}
|
||||
{activeFilter === "chats" &&
|
||||
(isLoadingMore || isSearching) && (
|
||||
<div className="flex justify-center items-center py-3">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-solid border-text-04 border-t-text-02" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Projects section - show if filter is 'all' or 'projects' */}
|
||||
{(activeFilter === "all" || activeFilter === "projects") &&
|
||||
displayedProjects.length > 0 && (
|
||||
<>
|
||||
{(activeFilter === "all" || activeFilter === "projects") && (
|
||||
<CommandMenu.Filter
|
||||
value="projects"
|
||||
onSelect={() => setActiveFilter("projects")}
|
||||
isApplied={activeFilter === "projects"}
|
||||
>
|
||||
Projects
|
||||
</CommandMenu.Filter>
|
||||
)}
|
||||
{displayedProjects.map((project) => (
|
||||
<CommandMenu.Item
|
||||
key={project.id}
|
||||
value={`project-${project.id}`}
|
||||
icon={SvgFolder}
|
||||
rightContent={({ isHighlighted }) =>
|
||||
isHighlighted ? (
|
||||
<Text figureKeystroke text02>
|
||||
↵
|
||||
</Text>
|
||||
) : (
|
||||
<Text secondaryBody text03>
|
||||
{formatDisplayTime(project.time)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
onSelect={() => handleProjectSelect(project.id)}
|
||||
>
|
||||
{project.label}
|
||||
</CommandMenu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* New Project action - shown when no search and no filter or projects filter */}
|
||||
{!hasSearchValue &&
|
||||
(activeFilter === "all" || activeFilter === "projects") && (
|
||||
<CommandMenu.Action
|
||||
value="new-project"
|
||||
icon={SvgFolderPlus}
|
||||
onSelect={handleNewProject}
|
||||
>
|
||||
New Project
|
||||
</CommandMenu.Action>
|
||||
)}
|
||||
</CommandMenu.List>
|
||||
|
||||
<DynamicFooter />
|
||||
</CommandMenu.Content>
|
||||
</CommandMenu>
|
||||
|
||||
{/* Project creation modal */}
|
||||
<createProjectModal.Provider>
|
||||
<CreateProjectModal />
|
||||
</createProjectModal.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
web/src/sections/sidebar/chatSearchUtils.ts
Normal file
54
web/src/sections/sidebar/chatSearchUtils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Formats a date string for display in the chat search menu.
|
||||
* Examples: "just now", "5 mins ago", "3 hours ago", "yesterday", "3 days ago", "October 23"
|
||||
*/
|
||||
export function formatDisplayTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
||||
if (diffMs < 0) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Just now (less than 1 minute)
|
||||
if (diffMins < 1) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
// X mins ago (1-59 minutes)
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins} ${diffMins === 1 ? "min" : "mins"} ago`;
|
||||
}
|
||||
|
||||
// X hours ago (1-23 hours)
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} ${diffHours === 1 ? "hour" : "hours"} ago`;
|
||||
}
|
||||
|
||||
// Check if yesterday
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (
|
||||
date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear()
|
||||
) {
|
||||
return "yesterday";
|
||||
}
|
||||
|
||||
// X days ago (2-7 days)
|
||||
if (diffDays <= 7) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
// Month Day format (e.g., "October 23")
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
231
web/src/sections/sidebar/useChatSearchOptimistic.ts
Normal file
231
web/src/sections/sidebar/useChatSearchOptimistic.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { useProjects } from "@/lib/hooks/useProjects";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { ChatSearchResponse } from "@/app/chat/chat_search/interfaces";
|
||||
import { UNNAMED_CHAT } from "@/lib/constants";
|
||||
|
||||
export interface FilterableChat {
|
||||
id: string;
|
||||
label: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface UseChatSearchOptimisticOptions {
|
||||
searchQuery: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UseChatSearchOptimisticResult {
|
||||
results: FilterableChat[];
|
||||
isSearching: boolean;
|
||||
hasMore: boolean;
|
||||
fetchMore: () => Promise<void>;
|
||||
isLoadingMore: boolean;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function transformApiResponse(response: ChatSearchResponse): FilterableChat[] {
|
||||
const chats: FilterableChat[] = [];
|
||||
for (const group of response.groups) {
|
||||
for (const chat of group.chats) {
|
||||
chats.push({
|
||||
id: chat.id,
|
||||
label: chat.name || UNNAMED_CHAT,
|
||||
time: chat.time_created,
|
||||
});
|
||||
}
|
||||
}
|
||||
return chats;
|
||||
}
|
||||
|
||||
function filterLocalSessions(
|
||||
sessions: FilterableChat[],
|
||||
searchQuery: string
|
||||
): FilterableChat[] {
|
||||
if (!searchQuery.trim()) {
|
||||
return sessions;
|
||||
}
|
||||
const term = searchQuery.toLowerCase();
|
||||
return sessions.filter((chat) => chat.label.toLowerCase().includes(term));
|
||||
}
|
||||
|
||||
// --- Hook ---
|
||||
|
||||
export function useChatSearchOptimistic(
|
||||
options: UseChatSearchOptimisticOptions
|
||||
): UseChatSearchOptimisticResult {
|
||||
const { searchQuery, enabled = true } = options;
|
||||
|
||||
// Debounced search query for API calls
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
|
||||
|
||||
// Ref for infinite scroll sentinel
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 1. Get already-cached data from existing hooks
|
||||
const { chatSessions } = useChatSessions();
|
||||
const { projects } = useProjects();
|
||||
|
||||
// 2. Build combined fallback data (instant display)
|
||||
const fallbackSessions = useMemo<FilterableChat[]>(() => {
|
||||
const chatMap = new Map<string, FilterableChat>();
|
||||
|
||||
// Add regular chats from useChatSessions
|
||||
for (const chat of chatSessions) {
|
||||
chatMap.set(chat.id, {
|
||||
id: chat.id,
|
||||
label: chat.name || UNNAMED_CHAT,
|
||||
time: chat.time_updated || chat.time_created,
|
||||
});
|
||||
}
|
||||
|
||||
// Add project chats from useProjects
|
||||
for (const project of projects) {
|
||||
for (const chat of project.chat_sessions) {
|
||||
chatMap.set(chat.id, {
|
||||
id: chat.id,
|
||||
label: chat.name || UNNAMED_CHAT,
|
||||
time: chat.time_updated || chat.time_created,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by most recent
|
||||
return Array.from(chatMap.values()).sort(
|
||||
(a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()
|
||||
);
|
||||
}, [chatSessions, projects]);
|
||||
|
||||
// Debounce the search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery), DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 3. SWR key generator for infinite scroll
|
||||
const getKey = useCallback(
|
||||
(pageIndex: number, previousPageData: ChatSearchResponse | null) => {
|
||||
// Don't fetch if not enabled
|
||||
if (!enabled) return null;
|
||||
|
||||
// Reached the end
|
||||
if (previousPageData && !previousPageData.has_more) return null;
|
||||
|
||||
const page = pageIndex + 1;
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", page.toString());
|
||||
params.set("page_size", PAGE_SIZE.toString());
|
||||
|
||||
if (debouncedQuery.trim()) {
|
||||
params.set("query", debouncedQuery);
|
||||
}
|
||||
|
||||
return `/api/chat/search?${params.toString()}`;
|
||||
},
|
||||
[enabled, debouncedQuery]
|
||||
);
|
||||
|
||||
// 4. Use SWR for paginated data (replaces fallback after fetch)
|
||||
const { data, size, setSize, isValidating } =
|
||||
useSWRInfinite<ChatSearchResponse>(getKey, errorHandlingFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30000,
|
||||
revalidateFirstPage: false,
|
||||
persistSize: true,
|
||||
});
|
||||
|
||||
// Transform SWR data to FilterableChat[]
|
||||
const swrResults = useMemo<FilterableChat[]>(() => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
const allChats: FilterableChat[] = [];
|
||||
for (const page of data) {
|
||||
allChats.push(...transformApiResponse(page));
|
||||
}
|
||||
|
||||
// Deduplicate by id (keep first occurrence)
|
||||
const seen = new Set<string>();
|
||||
return allChats.filter((chat) => {
|
||||
if (seen.has(chat.id)) return false;
|
||||
seen.add(chat.id);
|
||||
return true;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
// Determine if we have more pages
|
||||
const hasMore = useMemo(() => {
|
||||
if (!data || data.length === 0) return true;
|
||||
const lastPage = data[data.length - 1];
|
||||
return lastPage?.has_more ?? false;
|
||||
}, [data]);
|
||||
|
||||
// 5. Return fallback if no SWR data yet, otherwise return SWR data
|
||||
const results = useMemo<FilterableChat[]>(() => {
|
||||
// If SWR has data, use it (paginated, searchable)
|
||||
if (swrResults.length > 0) {
|
||||
return swrResults;
|
||||
}
|
||||
|
||||
// Otherwise use fallback (already-cached data)
|
||||
// Apply local filtering if there's a search query
|
||||
if (searchQuery.trim()) {
|
||||
return filterLocalSessions(fallbackSessions, searchQuery);
|
||||
}
|
||||
|
||||
return fallbackSessions;
|
||||
}, [swrResults, fallbackSessions, searchQuery]);
|
||||
|
||||
// Loading states
|
||||
const isSearching = isValidating && size === 1;
|
||||
const isLoadingMore = isValidating && size > 1;
|
||||
|
||||
// Fetch more results for infinite scroll
|
||||
const fetchMore = useCallback(async () => {
|
||||
if (!enabled || isValidating || !hasMore) {
|
||||
return;
|
||||
}
|
||||
await setSize(size + 1);
|
||||
}, [enabled, isValidating, hasMore, setSize, size]);
|
||||
|
||||
// IntersectionObserver for infinite scroll
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel || !enabled) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting && hasMore && !isValidating) {
|
||||
fetchMore();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: "100px",
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [enabled, hasMore, isValidating, fetchMore]);
|
||||
|
||||
return {
|
||||
results,
|
||||
isSearching,
|
||||
hasMore,
|
||||
fetchMore,
|
||||
isLoadingMore,
|
||||
sentinelRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user