Compare commits

...

17 Commits

Author SHA1 Message Date
SubashMohan
3fc2934a58 feat(sidebar): update action button layout in AppSidebar for improved UI 2026-01-25 21:21:22 +05:30
SubashMohan
4541bc6f65 fix(fetcher): include credentials in fetch request for improved authentication handling 2026-01-25 21:17:55 +05:30
SubashMohan
85ca6494c6 refactor(commandmenu): remove CommandMenu index file as part of cleanup 2026-01-25 20:50:18 +05:30
SubashMohan
200c866c26 refactor(commandmenu): remove unused hooks and streamline CommandMenu functionality 2026-01-25 20:50:18 +05:30
SubashMohan
846b315033 feat(scroll-indicator): implement scroll throttling for performance improvement 2026-01-25 20:50:18 +05:30
SubashMohan
79043484f5 feat(icons): add SvgArrowUpDown and SvgKeystroke components; update icon exports
- Introduced new SvgArrowUpDown and SvgKeystroke icon components for enhanced UI options.
- Updated index.ts to export the new icons, ensuring they are available for use throughout the application.
- Enhanced CommandMenu with a DynamicFooter to utilize the new icons based on highlighted item type.
2026-01-25 20:50:18 +05:30
SubashMohan
0fa69d7a50 feat(commandmenu): add mouse leave handling to CommandMenu for improved navigation
- Introduced onListMouseLeave callback to reset highlighted value when mouse leaves the command menu, enhancing user experience during navigation.
- Updated CommandMenuList to utilize the new mouse leave handler, ensuring better interaction feedback.
2026-01-25 20:50:18 +05:30
SubashMohan
81822f6f83 feat(commandmenu): enhance rightContent handling in CommandMenuItem
- Updated CommandMenuItem to support dynamic rightContent as a render function, allowing for conditional rendering based on item highlight state.
- Modified ChatSearchCommandMenu to utilize the new rightContent functionality, improving visual feedback for highlighted items.
2026-01-25 20:50:18 +05:30
SubashMohan
615f457d30 feat(divider): add keyboard navigation support and update styles
- Introduced a new divider.css file for keyboard navigation overrides, disabling hover effects when keyboard navigation is active.
- Updated line-item styles to ensure hover effects are disabled during keyboard navigation, enhancing accessibility.
- Modified Divider component to include a data-selected attribute for better state management.
- Enhanced EditableTag and CommandMenu components with updated styles and improved accessibility features.
2026-01-25 20:50:18 +05:30
SubashMohan
9f897a540d fix(commandmenu): update Divider usage and enhance ChatSearchCommandMenu functionality
- Modified CommandMenuFilter to disable the divider line when a filter is applied for better visual clarity.
- Refactored ChatSearchCommandMenu to streamline chat session handling and improve optimistic search integration.
- Updated header filters to reflect the active filter state more accurately, enhancing user experience.
2026-01-25 20:50:18 +05:30
SubashMohan
be0c2dbfaa feat(chat-search): implement optimistic search for chat sessions with infinite scroll
- Added a new  hook to manage chat session searches with optimistic UI updates.
- Integrated optimistic search results into , allowing for immediate feedback during searches.
- Enhanced  item registration to support different item types (filter, item, action).
- Updated UI to include loading indicators and infinite scroll functionality for chat results.
2026-01-25 20:50:18 +05:30
SubashMohan
367406daf1 feat(divider): introduce a new Divider component for enhanced UI separation and interactivity
- Added a versatile Divider component that supports both simple and title dividers with optional foldable behavior.
- Updated CommandMenu to utilize the new Divider component for improved visual structure.
- Enhanced CommandMenuFilter to include unique identifiers for better navigation and selection.
2026-01-25 20:50:18 +05:30
SubashMohan
05246bfb8b feat(line-item): add muted styling variants for less prominent items 2026-01-25 20:50:18 +05:30
SubashMohan
d510f34ad7 refactor(commandmenu): update CommandMenuHeader layout and styling for improved UI consistency 2026-01-25 20:50:18 +05:30
SubashMohan
6933bd9551 feat(commandmenu): enhance accessibility and update header filters in ChatSearchCommandMenu 2026-01-25 20:50:18 +05:30
SubashMohan
d383d87b1f feat(commandmenu): enhance CommandMenu with centralized keyboard navigation and item registration
- Introduced a new  function for DOM querying.
- Refactored item registration to use a callback registry for better separation of concerns.
- Updated keyboard navigation logic to handle arrow keys and Enter key centrally.
- Simplified item components to be dumb renderers, delegating interaction to context.
- Added a new  component for searching chats and projects.
- Implemented utility for formatting display time in chat search results.
2026-01-25 20:50:18 +05:30
SubashMohan
a8719691cd feat(commandmenu): add CommandMenu component with keyboard navigation and filtering capabilities 2026-01-25 20:50:18 +05:30
18 changed files with 2117 additions and 12 deletions

View 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;

View File

@@ -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";

View 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;

View File

@@ -40,6 +40,7 @@ export async function fetchChatSessions(
headers: {
"Content-Type": "application/json",
},
signal: params.signal,
});
if (!response.ok) {

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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(

View 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>
);
}

View File

@@ -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(() => {

View 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>
);
}

View File

@@ -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";

View 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,
});

View 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;
}

View File

@@ -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 ? (

View 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>
</>
);
}

View 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",
});
}

View 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,
};
}