feat(workspace): composer like Claude Desktop, image thumbnails, tool icons in the picker

This commit is contained in:
2026-10-02 16:13:27 +07:00
parent 4802684d2f
commit f35bf2043f
15 changed files with 414 additions and 185 deletions
+31 -6
View File
@@ -63,7 +63,7 @@
*
* ----------------------------------------------------------------------------- */
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
/** Single option in a {@link Select} list. */
@@ -74,6 +74,8 @@ export interface SelectOption<T extends string> {
label: string;
/** Optional secondary line (smaller gray text). */
hint?: string;
/** Optional leading icon, shown in the trigger and the list. */
icon?: ReactNode;
}
/** Props for {@link Select}. */
@@ -86,13 +88,25 @@ export interface SelectProps<T extends string> {
options: SelectOption<T>[];
/** Disables opening the list. */
disabled?: boolean;
/** `ghost`: a borderless, content-wide trigger for a composer's toolbar. */
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
}
/**
* Accessible custom select control.
* @typeParam T - string union of allowed option values.
*/
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
export function Select<T extends string>({
value,
onChange,
options,
disabled,
variant = "field",
label,
}: SelectProps<T>) {
const ghost = variant === "ghost";
const [open, setOpen] = useState(false);
const [active, setActive] = useState(() =>
Math.max(
@@ -166,21 +180,31 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
const current = options.find((o) => o.value === value) || options[0];
return (
<div ref={containerRef} className="relative">
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
aria-label={label}
aria-haspopup="listbox"
title={label}
onClick={() => setOpen((v) => !v)}
onKeyDown={onKey}
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
className={
ghost
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-1 focus-visible:ring-accent/50 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
}
>
<span className="truncate">{current?.label ?? "-"}</span>
<span className="flex min-w-0 items-center gap-2">
{current?.icon}
<span className="truncate">{current?.label ?? "-"}</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
</button>
{open && (
<div
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
@@ -199,6 +223,7 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"