diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 572f00ac3..a694b1a27 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -87,6 +87,9 @@ export const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), + favorites: Schema.Array(Schema.String.check(Schema.isMaxLength(256))).pipe( + withDefaults(() => []), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/FavesDropdown.tsx b/apps/web/src/components/FavesDropdown.tsx new file mode 100644 index 000000000..1e516967e --- /dev/null +++ b/apps/web/src/components/FavesDropdown.tsx @@ -0,0 +1,175 @@ +import { HeartIcon, PlusIcon, XIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { useAppSettings } from "../appSettings"; +import { cn } from "../lib/utils"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { SidebarMenuButton } from "./ui/sidebar"; + +const MAX_FAVORITES = 64; +const MAX_FAVORITE_LENGTH = 256; + +function normalizeFavorite(raw: string): string { + return raw.trim().slice(0, MAX_FAVORITE_LENGTH); +} + +export function FavesDropdown() { + const { settings, updateSettings } = useAppSettings(); + const favorites = settings.favorites; + const [isAdding, setIsAdding] = useState(false); + const [newFav, setNewFav] = useState(""); + const inputRef = useRef(null); + + const addFavorite = useCallback( + (raw: string) => { + const value = normalizeFavorite(raw); + if (!value) return; + if (favorites.includes(value)) return; + if (favorites.length >= MAX_FAVORITES) return; + updateSettings({ favorites: [...favorites, value] }); + setNewFav(""); + setIsAdding(false); + }, + [favorites, updateSettings], + ); + + const removeFavorite = useCallback( + (value: string) => { + updateSettings({ favorites: favorites.filter((f) => f !== value) }); + }, + [favorites, updateSettings], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + addFavorite(newFav); + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setIsAdding(false); + setNewFav(""); + } + }, + [addFavorite, newFav], + ); + + return ( + + + + } + > + + Faves + {favorites.length > 0 && ( + + {favorites.length} + + )} + + } + /> + Your favorites + + + + + Faves + {favorites.length === 0 && !isAdding && ( +
+ No favorites yet. Click + to add one. +
+ )} + {favorites.map((fav) => ( + + {fav} + + + ))} +
+ + + + {isAdding ? ( +
+ setNewFav(event.target.value)} + onKeyDown={handleKeyDown} + autoFocus + maxLength={MAX_FAVORITE_LENGTH} + /> + +
+ ) : ( + { + event.preventDefault(); + event.stopPropagation(); + setIsAdding(true); + queueMicrotask(() => inputRef.current?.focus()); + }} + > + + Add favorite + + )} +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bcb84319c..d31792397 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -96,6 +96,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { useTheme } from "~/hooks/useTheme"; +import { FavesDropdown } from "~/components/FavesDropdown"; import { getVisibleThreadsForProject, isActionableThreadStatus, @@ -2105,6 +2106,9 @@ export default function Sidebar() { Skills + + +