add project colors and icon support to chat input toolbar (#83)

* feat(chat): enhance chat input components with project color and icon support

- Added optional `color` property to `ProjectOption` interface for project color representation.
- Updated `ChatInputSelectorItem` to include an optional `icon` property for enhanced item display.
- Introduced `ProjectDot` component in `ChatInputToolbar` to visually represent project colors.
- Modified `ChatView` and `HomeScreen` to utilize the new `color` property for projects.
- Improved button components to support left and right icons for better UI flexibility.

* fix: apply biome formatting and guard asChild against icon props
This commit is contained in:
morgmart
2026-04-02 10:45:08 -07:00
committed by GitHub
parent 15f512eae3
commit 7b09bdcc18
8 changed files with 231 additions and 103 deletions
@@ -33,6 +33,7 @@ export interface ProjectOption {
id: string;
name: string;
workingDir?: string | null;
color?: string | null;
}
interface ChatInputProps {
@@ -16,6 +16,7 @@ export interface ChatInputSelectorItem {
value: string;
label: string;
description?: string;
icon?: ReactNode;
disabled?: boolean;
}
@@ -65,14 +66,14 @@ export function ChatInputSelector({
aria-label={ariaLabel}
title={triggerTitle}
disabled={disabled}
leftIcon={icon}
rightIcon={<ChevronDown />}
className={cn(
"min-w-0",
triggerVariant === "default" && "justify-between",
)}
>
{icon}
<span className="truncate">{triggerLabel}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -102,8 +103,11 @@ export function ChatInputSelector({
key={item.value}
disabled={item.disabled}
onSelect={() => onValueChange(item.value)}
className="items-start justify-between"
className="gap-2 items-start justify-between"
>
{item.icon ? (
<span className="mt-0.5 shrink-0">{item.icon}</span>
) : null}
<div className="min-w-0 flex-1">
<span className="block truncate font-medium">
{item.label}
@@ -4,6 +4,7 @@ import {
ChevronDown,
Check,
ArrowUp,
Sparkles,
Square,
} from "lucide-react";
import type { AcpProvider } from "@/shared/api/acp";
@@ -28,6 +29,19 @@ const NO_PROJECT_VALUE = "__no_project__";
const CREATE_PROJECT_VALUE = "__create_project__";
const CREATE_PROJECT_FROM_FOLDER_VALUE = "__create_project_from_folder__";
function ProjectDot({ color }: { color?: string | null }) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block size-2 rounded-full",
color ? "" : "bg-foreground-tertiary/40",
)}
style={color ? { backgroundColor: color } : undefined}
/>
);
}
function formatProviderLabel(providerId: string) {
return providerId
.split(/[-_\s]+/)
@@ -117,10 +131,6 @@ export function ChatInputToolbar({
const providerLabel =
availableProviderItems.find((provider) => provider.id === selectedProvider)
?.label ?? formatProviderLabel(selectedProvider);
const actionButtonClass =
"rounded-lg text-foreground-secondary hover:bg-background-tertiary hover:text-foreground";
const disabledActionButtonClass =
"rounded-lg text-foreground-tertiary/35 hover:bg-transparent hover:text-foreground-tertiary/35";
const handleProjectValueChange = (value: string) => {
if (value === CREATE_PROJECT_VALUE) {
@@ -145,6 +155,7 @@ export function ChatInputToolbar({
ariaLabel="Override provider"
value={selectedProvider}
triggerLabel={providersLoading ? "Loading..." : providerLabel}
icon={<Sparkles aria-hidden="true" />}
triggerVariant="toolbar"
triggerSize="sm"
menuLabel="Provider Override"
@@ -168,6 +179,7 @@ export function ChatInputToolbar({
type="button"
variant="ghost"
size="sm"
rightIcon={<ChevronDown className="opacity-50" />}
className="gap-1.5 rounded-lg px-2.5 text-xs text-foreground-tertiary hover:bg-background-tertiary hover:text-foreground"
aria-label="Select model"
>
@@ -175,7 +187,6 @@ export function ChatInputToolbar({
{isCompact && (
<span className="max-w-[60px] truncate">{currentModel}</span>
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
@@ -211,6 +222,7 @@ export function ChatInputToolbar({
value={selectedProjectId ?? NO_PROJECT_VALUE}
triggerLabel={projectLabel}
triggerTitle={projectTitle}
icon={<ProjectDot color={selectedProject?.color} />}
triggerVariant="toolbar"
triggerSize="sm"
menuLabel="Project"
@@ -222,11 +234,13 @@ export function ChatInputToolbar({
value: NO_PROJECT_VALUE,
label: "No project",
description: "General chat without project context",
icon: <ProjectDot />,
},
...availableProjects.map((project) => ({
value: project.id,
label: project.name,
description: project.workingDir ?? undefined,
icon: <ProjectDot color={project.color} />,
})),
],
},
@@ -256,99 +270,91 @@ export function ChatInputToolbar({
</div>
{/* Right side: actions */}
<div className="flex items-center gap-1">
{personas.length > 0 && (
<PersonaPicker
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={(id) => onPersonaChange?.(id)}
onCreatePersona={onCreatePersona}
triggerVariant="icon"
/>
)}
<div className="flex items-center">
<div className="flex items-center gap-px">
{personas.length > 0 && (
<PersonaPicker
personas={personas}
selectedPersonaId={selectedPersonaId}
onPersonaChange={(id) => onPersonaChange?.(id)}
onCreatePersona={onCreatePersona}
triggerVariant="icon"
/>
)}
{contextLimit > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={actionButtonClass}
aria-label="Context usage"
>
<ContextRing tokens={contextTokens} limit={contextLimit} />
</Button>
</TooltipTrigger>
<TooltipContent>
{contextTokens.toLocaleString()} / {contextLimit.toLocaleString()}{" "}
tokens
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
{contextLimit > 0 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"cursor-not-allowed disabled:pointer-events-auto disabled:opacity-100",
disabledActionButtonClass,
)}
disabled
aria-label="Voice input (coming soon)"
className="rounded-lg text-foreground-secondary hover:bg-background-tertiary hover:text-foreground"
aria-label="Context usage"
title={`${contextTokens.toLocaleString()} / ${contextLimit.toLocaleString()} tokens`}
>
<Mic className="h-3.5 w-3.5" />
<ContextRing tokens={contextTokens} limit={contextLimit} />
</Button>
</TooltipTrigger>
<TooltipContent>Voice input (coming soon)</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={actionButtonClass}
aria-label="Attach file"
>
<Paperclip className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
{isStreaming ? (
<Button
type="button"
onClick={onStop}
variant="ghost"
size="icon-sm"
className="rounded-full bg-background-danger/10 text-foreground-danger hover:bg-background-danger/20 hover:text-foreground-danger"
aria-label="Stop generation"
aria-label="Attach file"
title="Attach file"
>
<Square className="h-3.5 w-3.5" />
<Paperclip />
</Button>
) : (
<Button
type="button"
onClick={onSend}
disabled={!canSend}
size="icon-sm"
className={cn(
"rounded-full",
canSend
? "bg-foreground text-background-primary shadow-none hover:bg-foreground/90"
: "cursor-default bg-foreground/10 text-foreground-tertiary disabled:opacity-100",
)}
aria-label="Send message"
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled
aria-label="Voice input (coming soon)"
>
<Mic />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Voice input (coming soon)</TooltipContent>
</Tooltip>
</div>
<div className="ml-2">
{isStreaming ? (
<Button
type="button"
onClick={onStop}
variant="ghost"
size="icon-sm"
className="rounded-full bg-background-danger/10 text-foreground-danger hover:bg-background-danger/20 hover:text-foreground-danger"
aria-label="Stop generation"
title="Stop generation"
>
<Square className="h-3.5 w-3.5" />
</Button>
) : (
<Button
type="button"
onClick={onSend}
disabled={!canSend}
size="icon-sm"
className={cn(
"rounded-full",
canSend
? "bg-foreground text-background-primary shadow-none hover:bg-foreground/90"
: "cursor-default bg-foreground/10 text-foreground-tertiary disabled:opacity-100",
)}
aria-label="Send message"
title={canSend ? "Send message" : undefined}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
);
@@ -84,6 +84,7 @@ export function ChatView({
id: projectInfo.id,
name: projectInfo.name,
workingDir: projectInfo.workingDirs[0] ?? null,
color: projectInfo.color,
})),
[projects],
);
@@ -59,13 +59,11 @@ export function PersonaPicker({
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"rounded-lg text-foreground-secondary hover:bg-background-tertiary hover:text-foreground",
className,
)}
className={className}
aria-label="Choose assistant"
title="Choose assistant"
>
<AtSign className="h-4 w-4" />
<AtSign />
</Button>
) : (
<Button
@@ -137,6 +137,7 @@ export function HomeScreen({
id: project.id,
name: project.name,
workingDir: project.workingDirs[0] ?? null,
color: project.color,
}))}
onProjectChange={setSelectedProjectId}
onCreateProject={(options) =>
+51
View File
@@ -0,0 +1,51 @@
# Shared UI Design System
Use the primitives in `src/shared/ui/` as the default way to build interface elements. Prefer extending these components over recreating their styling in feature code.
## Core Rules
- Prefer shared UI primitives over custom markup plus Tailwind classes.
- Prefer adding a variant or prop to a shared component over one-off styling in feature code.
- Keep feature code focused on composition, state, and content. Visual treatment should usually live in `src/shared/ui/`.
- If a pattern appears in more than one place, extract or extend a shared primitive instead of copying class strings.
## Buttons
- Use `Button` for clickable controls unless there is a strong reason not to.
- Use `variant` and `size` before adding custom classes.
- Use `leftIcon` and `rightIcon` for leading and trailing icons instead of manually placing icon children.
- Do not add spacing classes only to position button icons unless the design system cannot express the pattern yet.
- For icon-only actions, use the `icon-*` sizes instead of text button sizes.
- For active icon-only buttons, prefer native hover via `title` plus `aria-label`.
- Reserve custom `Tooltip` for disabled controls or richer explanatory content.
## Icon Sizing
- Let `Button` size button icons by default.
- Current button icon defaults are tied to button size:
- `xs` and `sm` buttons use `size-3` icons.
- `default` buttons use `size-3.5` icons.
- `lg` buttons use `size-4` icons.
- Only give an icon its own explicit `size-*` class when intentionally overriding the design-system default.
- Match icon visual weight to the text and control size. Small toolbar controls should not use oversized icons.
## Menus And Selectors
- Compose menus from `DropdownMenu`, `DropdownMenuTrigger`, `DropdownMenuContent`, and related shared primitives.
- When a trigger behaves like a button, use `Button` as the trigger surface.
- Repeated trigger patterns like icon + label + chevron should be expressed through shared props or shared wrapper components.
## Styling Boundaries
- Avoid introducing custom colors, spacing, radii, or typography in feature code when existing tokens and shared variants cover the need.
- If a control needs a new visual treatment, add it to the shared component API first.
- Keep accessibility built in: semantic elements, labels for icon-only buttons, and consistent focus states.
## Good Heuristic
Before writing custom classes in a feature, ask:
1. Can an existing shared component already do this?
2. Should this become a shared variant or prop?
3. Will another screen likely need the same pattern?
+77 -11
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -23,14 +23,18 @@ const buttonVariants = cva(
link: "text-brand underline-offset-4 hover:underline",
},
size: {
xs: "h-7 rounded-md px-2.5 text-xs",
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
"icon-xs": "h-7 w-7",
"icon-sm": "h-8 w-8",
"icon-lg": "h-10 w-10",
xs: "h-7 rounded-md px-2.5 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
default:
"h-9 px-4 py-2 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5",
sm: "h-8 rounded-md px-3 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
lg: "h-10 rounded-md px-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4",
icon: "h-9 w-9 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4",
"icon-xs":
"h-7 w-7 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3",
"icon-sm":
"h-8 w-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5",
"icon-lg":
"h-10 w-10 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-5",
},
},
compoundVariants: [
@@ -49,6 +53,30 @@ const buttonVariants = cva(
size: "default",
className: "gap-1.5 px-2.5 text-[13px]",
},
{
variant: "ghost",
size: "icon-xs",
className:
"bg-transparent text-foreground-secondary hover:bg-transparent hover:text-foreground active:bg-transparent",
},
{
variant: "ghost",
size: "icon-sm",
className:
"bg-transparent text-foreground-secondary hover:bg-transparent hover:text-foreground active:bg-transparent",
},
{
variant: "ghost",
size: "icon",
className:
"bg-transparent text-foreground-secondary hover:bg-transparent hover:text-foreground active:bg-transparent",
},
{
variant: "ghost",
size: "icon-lg",
className:
"bg-transparent text-foreground-secondary hover:bg-transparent hover:text-foreground active:bg-transparent",
},
],
defaultVariants: {
variant: "default",
@@ -60,10 +88,24 @@ const buttonVariants = cva(
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
};
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
(
{
className,
variant,
size,
asChild = false,
leftIcon,
rightIcon,
children,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -71,7 +113,31 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
>
{asChild ? (
children
) : (
<>
{leftIcon ? (
<span
data-slot="button-left-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{leftIcon}
</span>
) : null}
{children}
{rightIcon ? (
<span
data-slot="button-right-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{rightIcon}
</span>
) : null}
</>
)}
</Comp>
);
},
);