mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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) =>
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user