adds ability to star chats

This commit is contained in:
2026-05-28 22:47:45 -07:00
parent cb8ea935fa
commit a6c2ec664b
16 changed files with 779 additions and 145 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
import { Check, ChevronDown, Globe2, LoaderCircle, Menu, MessageSquare, Paperclip, Pencil, Plus, Rabbit, Search, SendHorizontal, Star, Trash2, X } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
@@ -25,6 +25,8 @@ import {
runSearchStream,
suggestChatTitle,
updateChatTitle,
updateChatStar,
updateSearchStar,
getMessageAttachments,
type ChatAttachment,
type ActiveRunsResponse,
@@ -48,6 +50,8 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -625,6 +629,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -639,6 +645,8 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
starred: search.starred,
starredAt: search.starredAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
@@ -690,7 +698,13 @@ function getSidebarSectionLabel(value: string) {
}
function buildSidebarSections(items: SidebarItem[]) {
return items.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
const starred = items
.filter((item) => item.starred)
.sort((a, b) => new Date(b.starredAt ?? b.updatedAt).getTime() - new Date(a.starredAt ?? a.updatedAt).getTime());
const unstarred = items.filter((item) => !item.starred);
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
const label = getSidebarSectionLabel(item.updatedAt);
const section = sections.find((candidate) => candidate.label === label);
if (section) {
@@ -699,7 +713,7 @@ function buildSidebarSections(items: SidebarItem[]) {
sections.push({ label, items: [item] });
}
return sections;
}, []);
}, sections);
}
export default function App() {
@@ -1253,6 +1267,11 @@ export default function App() {
return chats.find((chat) => chat.id === selectedItem.id) ?? null;
}, [chats, selectedItem]);
const selectedSidebarItem = useMemo(() => {
if (!selectedItem) return null;
return sidebarItems.find((item) => item.kind === selectedItem.kind && item.id === selectedItem.id) ?? null;
}, [selectedItem, sidebarItems]);
const selectedSearchSummary = useMemo(() => {
if (!selectedItem || selectedItem.kind !== "search") return null;
return searches.find((search) => search.id === selectedItem.id) ?? null;
@@ -1399,6 +1418,57 @@ export default function App() {
return sidebarItem?.title ?? "New chat";
};
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
setChats((current) => {
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
if (moveToFront) return [updatedChat, ...withoutExisting];
const existingIndex = current.findIndex((chat) => chat.id === updatedChat.id);
if (existingIndex < 0) return [updatedChat, ...current];
const next = [...current];
next[existingIndex] = updatedChat;
return next;
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), moveToFront));
setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current;
return {
...current,
title: updatedChat.title,
updatedAt: updatedChat.updatedAt,
starred: updatedChat.starred,
starredAt: updatedChat.starredAt,
initiatedProvider: updatedChat.initiatedProvider,
initiatedModel: updatedChat.initiatedModel,
lastUsedProvider: updatedChat.lastUsedProvider,
lastUsedModel: updatedChat.lastUsedModel,
};
});
};
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
setSearches((current) => {
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
if (moveToFront) return [updatedSearch, ...withoutExisting];
const existingIndex = current.findIndex((search) => search.id === updatedSearch.id);
if (existingIndex < 0) return [updatedSearch, ...current];
const next = [...current];
next[existingIndex] = updatedSearch;
return next;
});
setWorkspaceItems((current) => upsertWorkspaceItem(current, searchWorkspaceItem(updatedSearch), moveToFront));
setSelectedSearch((current) => {
if (!current || current.id !== updatedSearch.id) return current;
return {
...current,
title: updatedSearch.title,
query: updatedSearch.query,
updatedAt: updatedSearch.updatedAt,
starred: updatedSearch.starred,
starredAt: updatedSearch.starredAt,
};
});
};
const openRenameChatDialog = (chatId: string) => {
setContextMenu(null);
setRenameChatDraft(getRenameSeedTitle(chatId));
@@ -1409,7 +1479,7 @@ export default function App() {
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
event.preventDefault();
const menuWidth = 176;
const menuHeight = item.kind === "chat" ? 80 : 40;
const menuHeight = item.kind === "chat" ? 120 : 80;
const padding = 8;
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
@@ -1431,20 +1501,7 @@ export default function App() {
setError(null);
try {
const updatedChat = await updateChatTitle(renameChatDialog.chatId, title);
setChats((current) => [updatedChat, ...current.filter((chat) => chat.id !== updatedChat.id)]);
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat)));
setSelectedChat((current) => {
if (!current || current.id !== updatedChat.id) return current;
return {
...current,
title: updatedChat.title,
updatedAt: updatedChat.updatedAt,
initiatedProvider: updatedChat.initiatedProvider,
initiatedModel: updatedChat.initiatedModel,
lastUsedProvider: updatedChat.lastUsedProvider,
lastUsedModel: updatedChat.lastUsedModel,
};
});
applyChatSummary(updatedChat);
setRenameChatDialog(null);
setRenameChatDraft("");
} catch (err) {
@@ -1459,6 +1516,30 @@ export default function App() {
}
};
const handleToggleStar = async (target: SidebarSelection) => {
const current = sidebarItems.find((item) => item.kind === target.kind && item.id === target.id);
const nextStarred = !current?.starred;
setContextMenu(null);
setError(null);
try {
if (target.kind === "chat") {
const updatedChat = await updateChatStar(target.id, nextStarred);
applyChatSummary(updatedChat, false);
} else {
const updatedSearch = await updateSearchStar(target.id, nextStarred);
applySearchSummary(updatedSearch, false);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
}
};
const handleDeleteFromContextMenu = async () => {
if (!contextMenu || isItemRunning(contextMenu.item)) return;
const target = contextMenu.item;
@@ -1681,6 +1762,8 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -1901,6 +1984,8 @@ export default function App() {
query,
createdAt: currentSearch?.createdAt ?? nowIso,
updatedAt: nowIso,
starred: currentSearch?.starred ?? false,
starredAt: currentSearch?.starredAt ?? null,
requestId: null,
latencyMs: null,
error: null,
@@ -2258,6 +2343,8 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -2434,6 +2521,8 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
starred: chat.starred,
starredAt: chat.starredAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
@@ -2653,6 +2742,12 @@ export default function App() {
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{item.title}</span>
{item.starred ? (
<Star
className={cn("h-3.5 w-3.5 shrink-0 fill-amber-300", active ? "text-amber-200" : "text-amber-300/90")}
aria-label="Starred"
/>
) : null}
{itemIsRunning ? (
<LoaderCircle
className={cn("h-3.5 w-3.5 shrink-0 animate-spin", active ? "text-cyan-100" : "text-cyan-300/90")}
@@ -2693,6 +2788,19 @@ export default function App() {
<div className="flex min-w-0 items-center gap-1.5">
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
{draftKind === null && selectedItem ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-violet-100/72 hover:text-violet-50"
onClick={() => void handleToggleStar(selectedItem)}
title={selectedSidebarItem?.starred ? "Unstar" : "Star"}
aria-label={selectedSidebarItem?.starred ? "Unstar" : "Star"}
>
<Star className={cn("h-3.5 w-3.5", selectedSidebarItem?.starred ? "fill-amber-300 text-amber-300" : "")} />
</Button>
) : null}
{draftKind === null && selectedItem?.kind === "chat" ? (
<Button
type="button"
@@ -2877,6 +2985,21 @@ export default function App() {
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-violet-100 transition hover:bg-violet-400/12"
onClick={() => void handleToggleStar(contextMenu.item)}
>
<Star
className={cn(
"h-3.5 w-3.5",
sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred
? "fill-amber-300 text-amber-300"
: ""
)}
/>
{sidebarItems.find((item) => item.kind === contextMenu.item.kind && item.id === contextMenu.item.id)?.starred ? "Unstar" : "Star"}
</button>
{contextMenu.item.kind === "chat" ? (
<button
type="button"

View File

@@ -3,6 +3,8 @@ export type ChatSummary = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -15,6 +17,8 @@ export type SearchSummary = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export type ChatWorkspaceItem = ChatSummary & {
@@ -54,6 +58,8 @@ export type ChatDetail = {
title: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
@@ -83,6 +89,8 @@ export type SearchDetail = {
query: string | null;
createdAt: string;
updatedAt: string;
starred: boolean;
starredAt: string | null;
requestId: string | null;
latencyMs: number | null;
error: string | null;
@@ -263,6 +271,14 @@ export async function updateChatTitle(chatId: string, title: string) {
return data.chat;
}
export async function updateChatStar(chatId: string, starred: boolean) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/star`, {
method: "PATCH",
body: JSON.stringify({ starred }),
});
return data.chat;
}
export async function suggestChatTitle(body: { chatId: string; content: string }) {
const data = await api<{ chat: ChatSummary }>("/v1/chats/title/suggest", {
method: "POST",
@@ -293,6 +309,14 @@ export async function getSearch(searchId: string) {
return data.search;
}
export async function updateSearchStar(searchId: string, starred: boolean) {
const data = await api<{ search: SearchSummary }>(`/v1/searches/${searchId}/star`, {
method: "PATCH",
body: JSON.stringify({ starred }),
});
return data.search;
}
export async function createChatFromSearch(searchId: string, body?: { title?: string }) {
const data = await api<{ chat: ChatSummary }>(`/v1/searches/${searchId}/chat`, {
method: "POST",

View File

@@ -106,6 +106,8 @@ export default function SearchRoutePage() {
query: trimmed,
createdAt: nowIso,
updatedAt: nowIso,
starred: false,
starredAt: null,
requestId: null,
latencyMs: null,
error: null,
@@ -132,6 +134,8 @@ export default function SearchRoutePage() {
query: created.query,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
starred: created.starred,
starredAt: created.starredAt,
}
: current
);