|
|
|
@@ -3,6 +3,7 @@ import type { TargetedTouchEvent } from "preact";
|
|
|
|
|
import {
|
|
|
|
|
Check,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
GitFork,
|
|
|
|
|
Globe2,
|
|
|
|
|
LoaderCircle,
|
|
|
|
|
Menu,
|
|
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
|
|
createSearch,
|
|
|
|
|
deleteChat,
|
|
|
|
|
deleteSearch,
|
|
|
|
|
forkChat,
|
|
|
|
|
attachCompletionStream,
|
|
|
|
|
attachSearchStream,
|
|
|
|
|
getActiveRuns,
|
|
|
|
@@ -67,6 +69,12 @@ import {
|
|
|
|
|
getChatModelSelection,
|
|
|
|
|
getChatModelSelectionSyncKey,
|
|
|
|
|
} from "@/lib/chat-model-selection";
|
|
|
|
|
import {
|
|
|
|
|
filterSidebarItemsWithForkGroups,
|
|
|
|
|
getForkGroupPresentation,
|
|
|
|
|
groupForkedChatItems,
|
|
|
|
|
shouldRequestChatTitle,
|
|
|
|
|
} from "@/lib/chat-forking";
|
|
|
|
|
import {
|
|
|
|
|
resolveSidebarSelectionAfterRefresh,
|
|
|
|
|
type SidebarSelection,
|
|
|
|
@@ -76,6 +84,7 @@ import { cn } from "@/lib/utils";
|
|
|
|
|
type DraftSelectionKind = "chat" | "search";
|
|
|
|
|
type SidebarItem = SidebarSelection & {
|
|
|
|
|
title: string;
|
|
|
|
|
parentChatId: string | null;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
starred: boolean;
|
|
|
|
@@ -86,9 +95,16 @@ type SidebarItem = SidebarSelection & {
|
|
|
|
|
lastUsedModel: string | null;
|
|
|
|
|
};
|
|
|
|
|
type ContextMenuState = {
|
|
|
|
|
kind: "sidebar";
|
|
|
|
|
item: SidebarSelection;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
} | {
|
|
|
|
|
kind: "message";
|
|
|
|
|
chatId: string;
|
|
|
|
|
messageId: string;
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
};
|
|
|
|
|
type RenameChatDialogState = {
|
|
|
|
|
chatId: string;
|
|
|
|
@@ -809,6 +825,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|
|
|
|
kind: "chat" as const,
|
|
|
|
|
id: chat.id,
|
|
|
|
|
title: getChatTitle(chat),
|
|
|
|
|
parentChatId: chat.parentChatId,
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
createdAt: chat.createdAt,
|
|
|
|
|
starred: chat.starred,
|
|
|
|
@@ -825,6 +842,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
|
|
|
|
kind: "search" as const,
|
|
|
|
|
id: search.id,
|
|
|
|
|
title: getSearchTitle(search),
|
|
|
|
|
parentChatId: null,
|
|
|
|
|
updatedAt: search.updatedAt,
|
|
|
|
|
createdAt: search.createdAt,
|
|
|
|
|
starred: search.starred,
|
|
|
|
@@ -879,20 +897,33 @@ function getSidebarSectionLabel(value: string) {
|
|
|
|
|
return "EARLIER";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildSidebarSections(items: SidebarItem[]) {
|
|
|
|
|
const starred = items
|
|
|
|
|
.filter((item) => item.starred)
|
|
|
|
|
function buildSidebarSections(items: SidebarItem[], allItems = items) {
|
|
|
|
|
const groups = groupForkedChatItems(items).map((groupItems) => {
|
|
|
|
|
const presentation = getForkGroupPresentation(groupItems, allItems);
|
|
|
|
|
return {
|
|
|
|
|
items: groupItems,
|
|
|
|
|
updatedAt: presentation?.updatedAt ?? groupItems[0].updatedAt,
|
|
|
|
|
starred: presentation?.starred ?? false,
|
|
|
|
|
starredAt: presentation?.starredAt ?? null,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const starred = groups
|
|
|
|
|
.filter((group) => group.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 unstarred = groups
|
|
|
|
|
.filter((group) => !group.starred)
|
|
|
|
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
|
|
|
|
|
|
|
|
const sections = starred.length ? [{ label: "STARRED", items: starred }] : [];
|
|
|
|
|
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, item) => {
|
|
|
|
|
const label = getSidebarSectionLabel(item.updatedAt);
|
|
|
|
|
const sections: Array<{ label: string; items: SidebarItem[] }> = starred.length
|
|
|
|
|
? [{ label: "STARRED", items: starred.flatMap((group) => group.items) }]
|
|
|
|
|
: [];
|
|
|
|
|
return unstarred.reduce<Array<{ label: string; items: SidebarItem[] }>>((sections, group) => {
|
|
|
|
|
const label = getSidebarSectionLabel(group.updatedAt);
|
|
|
|
|
const section = sections.find((candidate) => candidate.label === label);
|
|
|
|
|
if (section) {
|
|
|
|
|
section.items.push(item);
|
|
|
|
|
section.items.push(...group.items);
|
|
|
|
|
} else {
|
|
|
|
|
sections.push({ label, items: [item] });
|
|
|
|
|
sections.push({ label, items: [...group.items] });
|
|
|
|
|
}
|
|
|
|
|
return sections;
|
|
|
|
|
}, sections);
|
|
|
|
@@ -922,6 +953,7 @@ export default function App() {
|
|
|
|
|
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
|
|
|
|
|
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
|
|
|
|
|
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
|
|
|
|
|
const [isForkingChat, setIsForkingChat] = useState(false);
|
|
|
|
|
const [pendingChatStates, setPendingChatStates] = useState<Record<string, PendingChatState>>({});
|
|
|
|
|
const [runningSearchStates, setRunningSearchStates] = useState<Record<string, SearchDetail>>({});
|
|
|
|
|
const [activeRuns, setActiveRuns] = useState<ActiveRunsState>(EMPTY_ACTIVE_RUNS);
|
|
|
|
@@ -979,6 +1011,12 @@ export default function App() {
|
|
|
|
|
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
|
|
|
|
const composerDraftRef = useRef("");
|
|
|
|
|
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
|
|
|
|
const draftKindRef = useRef<DraftSelectionKind | null>(null);
|
|
|
|
|
const collectionLoadRequestRef = useRef(0);
|
|
|
|
|
const activeCollectionLoadRef = useRef<{ requestId: number; options: RefreshCollectionsOptions } | null>(null);
|
|
|
|
|
const selectionLoadRequestRef = useRef(0);
|
|
|
|
|
const forkRequestSequenceRef = useRef(0);
|
|
|
|
|
const activeForkRequestRef = useRef<number | null>(null);
|
|
|
|
|
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const chatStreamAbortRefs = useRef<Map<string, AbortController>>(new Map());
|
|
|
|
|
const backgroundSuspendedChatStreamsRef = useRef<Set<string>>(new Set());
|
|
|
|
@@ -1063,17 +1101,32 @@ export default function App() {
|
|
|
|
|
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
|
|
|
|
const filteredSidebarItems = useMemo(() => {
|
|
|
|
|
const query = sidebarQuery.trim().toLowerCase();
|
|
|
|
|
if (!query) return sidebarItems;
|
|
|
|
|
return sidebarItems.filter((item) => {
|
|
|
|
|
if (!query) return groupForkedChatItems(sidebarItems).flat();
|
|
|
|
|
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => {
|
|
|
|
|
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
|
|
|
|
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.some((value) => String(value).toLowerCase().includes(query));
|
|
|
|
|
});
|
|
|
|
|
}, [sidebarItems, sidebarQuery]);
|
|
|
|
|
const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]);
|
|
|
|
|
const sidebarSections = useMemo(
|
|
|
|
|
() => buildSidebarSections(filteredSidebarItems, sidebarItems),
|
|
|
|
|
[filteredSidebarItems, sidebarItems]
|
|
|
|
|
);
|
|
|
|
|
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
|
|
|
|
|
|
|
|
|
|
const cancelCollectionLoad = () => {
|
|
|
|
|
collectionLoadRequestRef.current += 1;
|
|
|
|
|
activeCollectionLoadRef.current = null;
|
|
|
|
|
setIsLoadingCollections(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const resetWorkspaceState = () => {
|
|
|
|
|
cancelCollectionLoad();
|
|
|
|
|
selectionLoadRequestRef.current += 1;
|
|
|
|
|
forkRequestSequenceRef.current += 1;
|
|
|
|
|
activeForkRequestRef.current = null;
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setChats([]);
|
|
|
|
|
setSearches([]);
|
|
|
|
|
setWorkspaceItems([]);
|
|
|
|
@@ -1119,6 +1172,7 @@ export default function App() {
|
|
|
|
|
setRenameChatDraft("");
|
|
|
|
|
setRenameChatError(null);
|
|
|
|
|
setIsRenamingChat(false);
|
|
|
|
|
setIsForkingChat(false);
|
|
|
|
|
setError(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -1132,32 +1186,52 @@ export default function App() {
|
|
|
|
|
reportTransientError = true,
|
|
|
|
|
selectFallback = false,
|
|
|
|
|
}: RefreshCollectionsOptions = {}) => {
|
|
|
|
|
const requestId = ++collectionLoadRequestRef.current;
|
|
|
|
|
activeCollectionLoadRef.current = {
|
|
|
|
|
requestId,
|
|
|
|
|
options: { initialSelection, reportTransientError, selectFallback },
|
|
|
|
|
};
|
|
|
|
|
setIsLoadingCollections(true);
|
|
|
|
|
try {
|
|
|
|
|
const nextWorkspaceItems = await listWorkspaceItems();
|
|
|
|
|
if (requestId !== collectionLoadRequestRef.current) return;
|
|
|
|
|
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
|
|
|
|
setWorkspaceItems(nextWorkspaceItems);
|
|
|
|
|
setChats(nextChats);
|
|
|
|
|
setSearches(nextSearches);
|
|
|
|
|
|
|
|
|
|
setSelectedItem((current) =>
|
|
|
|
|
resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
|
|
|
|
setSelectedItem((current) => {
|
|
|
|
|
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
|
|
|
|
initialSelection,
|
|
|
|
|
selectFallback,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
selectedItemRef.current = nextSelection;
|
|
|
|
|
return nextSelection;
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else if (reportTransientError || !isRecoverableStreamDisconnect(err)) {
|
|
|
|
|
} else if (
|
|
|
|
|
requestId === collectionLoadRequestRef.current &&
|
|
|
|
|
(reportTransientError || !isRecoverableStreamDisconnect(err))
|
|
|
|
|
) {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingCollections(false);
|
|
|
|
|
if (requestId === collectionLoadRequestRef.current) {
|
|
|
|
|
activeCollectionLoadRef.current = null;
|
|
|
|
|
setIsLoadingCollections(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const supersedeCollectionLoad = () => {
|
|
|
|
|
const replacementOptions = activeCollectionLoadRef.current?.options;
|
|
|
|
|
cancelCollectionLoad();
|
|
|
|
|
if (replacementOptions) void refreshCollections(replacementOptions);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshModels = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await listModels();
|
|
|
|
@@ -1200,38 +1274,44 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshChat = async (chatId: string) => {
|
|
|
|
|
const requestId = ++selectionLoadRequestRef.current;
|
|
|
|
|
setIsLoadingSelection(true);
|
|
|
|
|
try {
|
|
|
|
|
const chat = await getChat(chatId);
|
|
|
|
|
const current = selectedItemRef.current;
|
|
|
|
|
if (requestId !== selectionLoadRequestRef.current || current?.kind !== "chat" || current.id !== chatId) return;
|
|
|
|
|
setSelectedChat(chat);
|
|
|
|
|
setSelectedSearch(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
|
|
|
|
} else if (requestId === selectionLoadRequestRef.current && !isRecoverableStreamDisconnect(err)) {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingSelection(false);
|
|
|
|
|
if (requestId === selectionLoadRequestRef.current) setIsLoadingSelection(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshSearch = async (searchId: string) => {
|
|
|
|
|
const requestId = ++selectionLoadRequestRef.current;
|
|
|
|
|
setIsLoadingSelection(true);
|
|
|
|
|
try {
|
|
|
|
|
const search = await getSearch(searchId);
|
|
|
|
|
const current = selectedItemRef.current;
|
|
|
|
|
if (requestId !== selectionLoadRequestRef.current || current?.kind !== "search" || current.id !== searchId) return;
|
|
|
|
|
setSelectedSearch(search);
|
|
|
|
|
setSelectedChat(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else if (!isRecoverableStreamDisconnect(err)) {
|
|
|
|
|
} else if (requestId === selectionLoadRequestRef.current && !isRecoverableStreamDisconnect(err)) {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoadingSelection(false);
|
|
|
|
|
if (requestId === selectionLoadRequestRef.current) setIsLoadingSelection(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -1296,8 +1376,11 @@ export default function App() {
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onPopState = () => {
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setSelectedItem(readSidebarSelectionFromUrl());
|
|
|
|
|
const target = readSidebarSelectionFromUrl();
|
|
|
|
|
selectedItemRef.current = target;
|
|
|
|
|
setSelectedItem(target);
|
|
|
|
|
setIsMobileSidebarOpen(false);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener("popstate", onPopState);
|
|
|
|
@@ -1410,6 +1493,29 @@ export default function App() {
|
|
|
|
|
|
|
|
|
|
const isItemRunning = (item: SidebarSelection) =>
|
|
|
|
|
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
|
|
|
|
|
const getChatFamilyItems = (chatId: string) => {
|
|
|
|
|
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
|
|
|
|
if (!chat) return [];
|
|
|
|
|
const rootId = chat.parentChatId ?? chat.id;
|
|
|
|
|
return sidebarItems.filter(
|
|
|
|
|
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
const isRootChat = (item: SidebarSelection) => {
|
|
|
|
|
if (item.kind !== "chat") return false;
|
|
|
|
|
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
|
|
|
|
|
return chat?.parentChatId === null;
|
|
|
|
|
};
|
|
|
|
|
const isDeleteBlocked = (item: SidebarSelection) => {
|
|
|
|
|
if (!isRootChat(item)) return isItemRunning(item);
|
|
|
|
|
return getChatFamilyItems(item.id).some((familyItem) => isItemRunning(familyItem));
|
|
|
|
|
};
|
|
|
|
|
const isSidebarTargetStarred = (item: SidebarSelection) => {
|
|
|
|
|
if (item.kind === "chat") {
|
|
|
|
|
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
|
|
|
|
}
|
|
|
|
|
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
|
|
|
|
|
};
|
|
|
|
|
const isCurrentSelection = (item: SidebarSelection) => {
|
|
|
|
|
const current = selectedItemRef.current;
|
|
|
|
|
return current?.kind === item.kind && current.id === item.id;
|
|
|
|
@@ -1428,6 +1534,10 @@ export default function App() {
|
|
|
|
|
selectedItemRef.current = selectedItem;
|
|
|
|
|
}, [selectedItem]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
draftKindRef.current = draftKind;
|
|
|
|
|
}, [draftKind]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
setSelectedChat(null);
|
|
|
|
@@ -1597,7 +1707,7 @@ export default function App() {
|
|
|
|
|
if (draftKind === "search") return "New search";
|
|
|
|
|
if (!selectedItem) return "Sybil";
|
|
|
|
|
if (selectedItem.kind === "chat") {
|
|
|
|
|
if (selectedChat) return getChatTitle(selectedChat, selectedChat.messages);
|
|
|
|
|
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
|
|
|
|
|
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
|
|
|
|
|
return "New chat";
|
|
|
|
|
}
|
|
|
|
@@ -1609,7 +1719,7 @@ export default function App() {
|
|
|
|
|
const pageTitle = useMemo(() => {
|
|
|
|
|
if (draftKind || !selectedItem) return "Sybil";
|
|
|
|
|
if (selectedItem.kind === "chat") {
|
|
|
|
|
if (selectedChat) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
|
|
|
|
|
if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
|
|
|
|
|
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
|
|
|
|
|
return "Sybil";
|
|
|
|
|
}
|
|
|
|
@@ -1631,6 +1741,7 @@ export default function App() {
|
|
|
|
|
const handleCreateChat = () => {
|
|
|
|
|
setError(null);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
draftKindRef.current = "chat";
|
|
|
|
|
setDraftKind("chat");
|
|
|
|
|
selectedItemRef.current = null;
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
@@ -1768,6 +1879,7 @@ export default function App() {
|
|
|
|
|
const handleCreateSearch = () => {
|
|
|
|
|
setError(null);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
draftKindRef.current = "search";
|
|
|
|
|
setDraftKind("search");
|
|
|
|
|
selectedItemRef.current = null;
|
|
|
|
|
setSelectedItem(null);
|
|
|
|
@@ -1780,24 +1892,26 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
|
|
|
|
|
if (!filteredSidebarItems.length) return;
|
|
|
|
|
if (!orderedSidebarItems.length) return;
|
|
|
|
|
|
|
|
|
|
setError(null);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setIsMobileSidebarOpen(false);
|
|
|
|
|
setSelectedItem((current) => {
|
|
|
|
|
const currentIndex = current
|
|
|
|
|
? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
|
|
|
|
: -1;
|
|
|
|
|
const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1;
|
|
|
|
|
const nextIndex =
|
|
|
|
|
currentIndex < 0
|
|
|
|
|
? fallbackIndex
|
|
|
|
|
: Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
|
|
|
|
const nextItem = filteredSidebarItems[nextIndex];
|
|
|
|
|
return { kind: nextItem.kind, id: nextItem.id };
|
|
|
|
|
});
|
|
|
|
|
const current = selectedItemRef.current;
|
|
|
|
|
const currentIndex = current
|
|
|
|
|
? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
|
|
|
|
|
: -1;
|
|
|
|
|
const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1;
|
|
|
|
|
const nextIndex =
|
|
|
|
|
currentIndex < 0
|
|
|
|
|
? fallbackIndex
|
|
|
|
|
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
|
|
|
|
const nextItem = orderedSidebarItems[nextIndex];
|
|
|
|
|
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
|
|
|
|
|
selectedItemRef.current = target;
|
|
|
|
|
setSelectedItem(target);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
@@ -1836,7 +1950,7 @@ export default function App() {
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
|
|
|
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
|
|
|
|
}, [isAuthenticated, isQuickQuestionOpen, orderedSidebarItems]);
|
|
|
|
|
|
|
|
|
|
const getRenameSeedTitle = (chatId: string) => {
|
|
|
|
|
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
|
|
|
|
@@ -1847,6 +1961,7 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
setChats((current) => {
|
|
|
|
|
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
|
|
|
|
if (moveToFront) return [updatedChat, ...withoutExisting];
|
|
|
|
@@ -1862,6 +1977,8 @@ export default function App() {
|
|
|
|
|
return {
|
|
|
|
|
...current,
|
|
|
|
|
title: updatedChat.title,
|
|
|
|
|
parentChatId: updatedChat.parentChatId,
|
|
|
|
|
titleGenerationPending: updatedChat.titleGenerationPending,
|
|
|
|
|
updatedAt: updatedChat.updatedAt,
|
|
|
|
|
starred: updatedChat.starred,
|
|
|
|
|
starredAt: updatedChat.starredAt,
|
|
|
|
@@ -1876,6 +1993,7 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
setSearches((current) => {
|
|
|
|
|
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
|
|
|
|
if (moveToFront) return [updatedSearch, ...withoutExisting];
|
|
|
|
@@ -1982,6 +2100,7 @@ export default function App() {
|
|
|
|
|
});
|
|
|
|
|
applyChatSummary(updatedChat);
|
|
|
|
|
} else if (!selectedItem && draftKind !== "chat") {
|
|
|
|
|
draftKindRef.current = "chat";
|
|
|
|
|
setDraftKind("chat");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2002,11 +2121,28 @@ export default function App() {
|
|
|
|
|
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const menuWidth = 176;
|
|
|
|
|
const menuHeight = item.kind === "chat" ? 120 : 80;
|
|
|
|
|
const menuHeight = item.kind === "chat" ? 152 : 80;
|
|
|
|
|
const padding = 8;
|
|
|
|
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
|
|
|
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
|
|
|
|
setContextMenu({ item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
|
|
|
|
setContextMenu({ kind: "sidebar", item, x: Math.max(padding, x), y: Math.max(padding, y) });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openMessageContextMenu = (event: MouseEvent, messageId: string) => {
|
|
|
|
|
if (selectedItem?.kind !== "chat" || messageId.startsWith("temp-")) return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const menuWidth = 176;
|
|
|
|
|
const menuHeight = 48;
|
|
|
|
|
const padding = 8;
|
|
|
|
|
const x = Math.min(event.clientX, window.innerWidth - menuWidth - padding);
|
|
|
|
|
const y = Math.min(event.clientY, window.innerHeight - menuHeight - padding);
|
|
|
|
|
setContextMenu({
|
|
|
|
|
kind: "message",
|
|
|
|
|
chatId: selectedItem.id,
|
|
|
|
|
messageId,
|
|
|
|
|
x: Math.max(padding, x),
|
|
|
|
|
y: Math.max(padding, y),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRenameChatSubmit = async (event?: Event) => {
|
|
|
|
@@ -2039,18 +2175,23 @@ export default function App() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleChatFamilyStar = async (chatId: string) => {
|
|
|
|
|
const family = getChatFamilyItems(chatId);
|
|
|
|
|
const root = family.find((item) => item.parentChatId === null) ?? family[0];
|
|
|
|
|
if (!root) return;
|
|
|
|
|
applyChatSummary(await updateChatStar(root.id, !root.starred), false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
await toggleChatFamilyStar(target.id);
|
|
|
|
|
} else {
|
|
|
|
|
const updatedSearch = await updateSearchStar(target.id, nextStarred);
|
|
|
|
|
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id);
|
|
|
|
|
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
|
|
|
|
|
applySearchSummary(updatedSearch, false);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
@@ -2063,17 +2204,57 @@ export default function App() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleForkChat = async (chatId: string, messageId?: string) => {
|
|
|
|
|
if (activeForkRequestRef.current !== null) return;
|
|
|
|
|
const requestId = ++forkRequestSequenceRef.current;
|
|
|
|
|
const selectionAtStart = selectedItemRef.current;
|
|
|
|
|
const draftKindAtStart = draftKindRef.current;
|
|
|
|
|
activeForkRequestRef.current = requestId;
|
|
|
|
|
setIsForkingChat(true);
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
const forkedChat = await forkChat(chatId, messageId);
|
|
|
|
|
if (activeForkRequestRef.current !== requestId) return;
|
|
|
|
|
const target: SidebarSelection = { kind: "chat", id: forkedChat.id };
|
|
|
|
|
applyChatSummary(forkedChat);
|
|
|
|
|
const currentSelection = selectedItemRef.current;
|
|
|
|
|
const selectionUnchanged = selectionAtStart
|
|
|
|
|
? currentSelection?.kind === selectionAtStart.kind && currentSelection.id === selectionAtStart.id
|
|
|
|
|
: currentSelection === null;
|
|
|
|
|
if (!selectionUnchanged || draftKindRef.current !== draftKindAtStart) return;
|
|
|
|
|
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
selectedItemRef.current = target;
|
|
|
|
|
setSelectedItem(target);
|
|
|
|
|
setSelectedChat(null);
|
|
|
|
|
setSelectedSearch(null);
|
|
|
|
|
setIsMobileSidebarOpen(false);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (activeForkRequestRef.current !== requestId) return;
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
|
handleAuthFailure(message);
|
|
|
|
|
} else {
|
|
|
|
|
setError(message);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (activeForkRequestRef.current === requestId) {
|
|
|
|
|
activeForkRequestRef.current = null;
|
|
|
|
|
setIsForkingChat(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleToggleChatSettingsStar = async () => {
|
|
|
|
|
if (draftKind !== null || selectedItem?.kind !== "chat" || isTogglingChatSettingsStar) return;
|
|
|
|
|
const current = sidebarItems.find((item) => item.kind === "chat" && item.id === selectedItem.id);
|
|
|
|
|
const nextStarred = !current?.starred;
|
|
|
|
|
setIsTogglingChatSettingsStar(true);
|
|
|
|
|
setChatSettingsError(null);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const updatedChat = await updateChatStar(selectedItem.id, nextStarred);
|
|
|
|
|
applyChatSummary(updatedChat, false);
|
|
|
|
|
await toggleChatFamilyStar(selectedItem.id);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
|
if (message.includes("bearer token")) {
|
|
|
|
@@ -2087,9 +2268,13 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteFromContextMenu = async () => {
|
|
|
|
|
if (!contextMenu || isItemRunning(contextMenu.item)) return;
|
|
|
|
|
if (!contextMenu || contextMenu.kind !== "sidebar" || isDeleteBlocked(contextMenu.item)) return;
|
|
|
|
|
const target = contextMenu.item;
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
if (isRootChat(target)) {
|
|
|
|
|
const confirmed = window.confirm("Delete this chat and all of its forks? This cannot be undone.");
|
|
|
|
|
if (!confirmed) return;
|
|
|
|
|
}
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
if (target.kind === "chat") {
|
|
|
|
@@ -2314,6 +2499,8 @@ export default function App() {
|
|
|
|
|
...(initialEnabledTools !== undefined ? { enabledTools: initialEnabledTools } : {}),
|
|
|
|
|
});
|
|
|
|
|
chatId = chat.id;
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setDraftChatTitle("");
|
|
|
|
|
setChats((current) => {
|
|
|
|
@@ -2321,10 +2508,14 @@ export default function App() {
|
|
|
|
|
return [chat, ...withoutExisting];
|
|
|
|
|
});
|
|
|
|
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
|
|
|
|
setSelectedItem({ kind: "chat", id: chatId });
|
|
|
|
|
const createdChatSelection: SidebarSelection = { kind: "chat", id: chatId };
|
|
|
|
|
selectedItemRef.current = createdChatSelection;
|
|
|
|
|
setSelectedItem(createdChatSelection);
|
|
|
|
|
setSelectedChat({
|
|
|
|
|
id: chat.id,
|
|
|
|
|
title: chat.title,
|
|
|
|
|
parentChatId: chat.parentChatId,
|
|
|
|
|
titleGenerationPending: chat.titleGenerationPending,
|
|
|
|
|
createdAt: chat.createdAt,
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
starred: chat.starred,
|
|
|
|
@@ -2378,24 +2569,12 @@ export default function App() {
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const chatSummary = chats.find((chat) => chat.id === chatId);
|
|
|
|
|
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
|
|
|
|
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
|
|
|
|
if (shouldRequestChatTitle(baseChat, pendingTitleGenerationRef.current.has(chatId))) {
|
|
|
|
|
pendingTitleGenerationRef.current.add(chatId);
|
|
|
|
|
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
|
|
|
|
|
void suggestChatTitle({ chatId, content: titleSeed })
|
|
|
|
|
.then((updatedChat) => {
|
|
|
|
|
setChats((current) =>
|
|
|
|
|
current.map((chat) => {
|
|
|
|
|
if (chat.id !== updatedChat.id) return chat;
|
|
|
|
|
return { ...chat, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(updatedChat), false));
|
|
|
|
|
setSelectedChat((current) => {
|
|
|
|
|
if (!current || current.id !== updatedChat.id) return current;
|
|
|
|
|
return { ...current, title: updatedChat.title, updatedAt: updatedChat.updatedAt };
|
|
|
|
|
});
|
|
|
|
|
applyChatSummary(updatedChat, false);
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// ignore title suggestion errors so chat flow is not interrupted
|
|
|
|
@@ -2568,8 +2747,12 @@ export default function App() {
|
|
|
|
|
title: query.slice(0, 80),
|
|
|
|
|
});
|
|
|
|
|
searchId = search.id;
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setSelectedItem({ kind: "search", id: searchId });
|
|
|
|
|
const createdSearchSelection: SidebarSelection = { kind: "search", id: searchId };
|
|
|
|
|
selectedItemRef.current = createdSearchSelection;
|
|
|
|
|
setSelectedItem(createdSearchSelection);
|
|
|
|
|
setSearches((current) => {
|
|
|
|
|
const withoutExisting = current.filter((existing) => existing.id !== search.id);
|
|
|
|
|
return [search, ...withoutExisting];
|
|
|
|
@@ -2964,6 +3147,8 @@ export default function App() {
|
|
|
|
|
setIsStartingSearchChat(true);
|
|
|
|
|
try {
|
|
|
|
|
const chat = await createChatFromSearch(sourceSearch.id);
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
replaceComposerDraft("");
|
|
|
|
|
setPendingAttachments([]);
|
|
|
|
@@ -2972,10 +3157,14 @@ export default function App() {
|
|
|
|
|
return [chat, ...withoutExisting];
|
|
|
|
|
});
|
|
|
|
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
|
|
|
|
setSelectedItem({ kind: "chat", id: chat.id });
|
|
|
|
|
const createdChatSelection: SidebarSelection = { kind: "chat", id: chat.id };
|
|
|
|
|
selectedItemRef.current = createdChatSelection;
|
|
|
|
|
setSelectedItem(createdChatSelection);
|
|
|
|
|
setSelectedChat({
|
|
|
|
|
id: chat.id,
|
|
|
|
|
title: chat.title,
|
|
|
|
|
parentChatId: chat.parentChatId,
|
|
|
|
|
titleGenerationPending: chat.titleGenerationPending,
|
|
|
|
|
createdAt: chat.createdAt,
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
starred: chat.starred,
|
|
|
|
@@ -3123,6 +3312,8 @@ export default function App() {
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
supersedeCollectionLoad();
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
replaceComposerDraft("");
|
|
|
|
|
setPendingAttachments([]);
|
|
|
|
@@ -3134,10 +3325,14 @@ export default function App() {
|
|
|
|
|
return [chat, ...withoutExisting];
|
|
|
|
|
});
|
|
|
|
|
setWorkspaceItems((current) => upsertWorkspaceItem(current, chatWorkspaceItem(chat)));
|
|
|
|
|
setSelectedItem({ kind: "chat", id: chat.id });
|
|
|
|
|
const createdChatSelection: SidebarSelection = { kind: "chat", id: chat.id };
|
|
|
|
|
selectedItemRef.current = createdChatSelection;
|
|
|
|
|
setSelectedItem(createdChatSelection);
|
|
|
|
|
setSelectedChat({
|
|
|
|
|
id: chat.id,
|
|
|
|
|
title: chat.title,
|
|
|
|
|
parentChatId: chat.parentChatId,
|
|
|
|
|
titleGenerationPending: chat.titleGenerationPending,
|
|
|
|
|
createdAt: chat.createdAt,
|
|
|
|
|
updatedAt: chat.updatedAt,
|
|
|
|
|
starred: chat.starred,
|
|
|
|
@@ -3218,9 +3413,7 @@ export default function App() {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const chatSettingsChatId = draftKind === null && selectedItem?.kind === "chat" ? selectedItem.id : null;
|
|
|
|
|
const chatSettingsStarred = chatSettingsChatId
|
|
|
|
|
? sidebarItems.find((item) => item.kind === "chat" && item.id === chatSettingsChatId)?.starred ?? false
|
|
|
|
|
: false;
|
|
|
|
|
const chatSettingsStarred = chatSettingsChatId ? isSidebarTargetStarred({ kind: "chat", id: chatSettingsChatId }) : false;
|
|
|
|
|
|
|
|
|
|
if (isCheckingSession) {
|
|
|
|
|
return (
|
|
|
|
@@ -3335,7 +3528,18 @@ export default function App() {
|
|
|
|
|
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
|
|
|
|
{section.items.map((item) => {
|
|
|
|
|
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
|
|
|
|
const itemIsRunning = isItemRunning(item);
|
|
|
|
|
const isForkChild = item.kind === "chat" && item.parentChatId !== null;
|
|
|
|
|
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
|
|
|
|
|
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
|
|
|
|
|
const itemIsStarred =
|
|
|
|
|
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
|
|
|
|
const itemUpdatedAt = familyItems.reduce(
|
|
|
|
|
(newest, familyItem) =>
|
|
|
|
|
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
|
|
|
|
|
? familyItem.updatedAt
|
|
|
|
|
: newest,
|
|
|
|
|
item.updatedAt
|
|
|
|
|
);
|
|
|
|
|
const initiatedLabel = item.kind === "chat" && item.initiatedModel
|
|
|
|
|
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
|
|
|
|
|
: null;
|
|
|
|
@@ -3343,53 +3547,62 @@ export default function App() {
|
|
|
|
|
<button
|
|
|
|
|
key={`${item.kind}-${item.id}`}
|
|
|
|
|
className={cn(
|
|
|
|
|
"mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
|
|
|
|
|
"mb-1 rounded-lg border text-left transition",
|
|
|
|
|
isForkChild ? "ml-7 w-[calc(100%-1.75rem)] px-3 py-1.5" : "w-full px-3 py-2.5",
|
|
|
|
|
active
|
|
|
|
|
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
|
|
|
|
|
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setContextMenu(null);
|
|
|
|
|
draftKindRef.current = null;
|
|
|
|
|
setDraftKind(null);
|
|
|
|
|
setSelectedItem({ kind: item.kind, id: item.id });
|
|
|
|
|
const target = { kind: item.kind, id: item.id } as SidebarSelection;
|
|
|
|
|
selectedItemRef.current = target;
|
|
|
|
|
setSelectedItem(target);
|
|
|
|
|
setIsMobileSidebarOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
|
|
|
|
|
type="button"
|
|
|
|
|
title={isForkChild ? item.title : undefined}
|
|
|
|
|
>
|
|
|
|
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
|
|
|
|
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
|
|
|
|
</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")}
|
|
|
|
|
aria-label="Generating"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="col-start-2 flex min-w-0 items-center gap-2">
|
|
|
|
|
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(item.updatedAt)}</span>
|
|
|
|
|
{initiatedLabel ? (
|
|
|
|
|
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
|
|
|
|
|
{initiatedLabel}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{isForkChild ? (
|
|
|
|
|
<span className="block truncate text-xs font-medium leading-5">{item.title}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1">
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
|
|
|
|
|
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
|
|
|
<span className="truncate text-sm font-semibold">{item.title}</span>
|
|
|
|
|
{itemIsStarred ? (
|
|
|
|
|
<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")}
|
|
|
|
|
aria-label="Generating"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="col-start-2 flex min-w-0 items-center gap-2">
|
|
|
|
|
<span className="shrink-0 text-xs text-secondary-foreground/70">{formatDate(itemUpdatedAt)}</span>
|
|
|
|
|
{initiatedLabel ? (
|
|
|
|
|
<span className="ml-auto min-w-0 truncate text-right text-xs text-secondary-foreground/70">
|
|
|
|
|
{initiatedLabel}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
@@ -3461,7 +3674,12 @@ export default function App() {
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{!isSearchMode ? (
|
|
|
|
|
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
|
|
|
|
|
<ChatMessagesPanel
|
|
|
|
|
messages={displayMessages}
|
|
|
|
|
isLoading={isLoadingSelection}
|
|
|
|
|
isSending={isSendingActiveChat}
|
|
|
|
|
onMessageContextMenu={openMessageContextMenu}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<SearchResultsPanel
|
|
|
|
|
search={selectedSearchForView}
|
|
|
|
@@ -3546,40 +3764,65 @@ 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" ? (
|
|
|
|
|
{contextMenu.kind === "message" ? (
|
|
|
|
|
<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={() => openRenameChatDialog(contextMenu.item.id)}
|
|
|
|
|
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 disabled:text-muted-foreground"
|
|
|
|
|
onClick={() => void handleForkChat(contextMenu.chatId, contextMenu.messageId)}
|
|
|
|
|
disabled={isForkingChat}
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
Rename
|
|
|
|
|
<GitFork className="h-3.5 w-3.5" />
|
|
|
|
|
Fork
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
|
|
|
|
onClick={() => void handleDeleteFromContextMenu()}
|
|
|
|
|
disabled={isItemRunning(contextMenu.item)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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",
|
|
|
|
|
isSidebarTargetStarred(contextMenu.item)
|
|
|
|
|
? "fill-amber-300 text-amber-300"
|
|
|
|
|
: ""
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{isSidebarTargetStarred(contextMenu.item) ? "Unstar" : "Star"}
|
|
|
|
|
</button>
|
|
|
|
|
{contextMenu.item.kind === "chat" ? (
|
|
|
|
|
<>
|
|
|
|
|
<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 disabled:text-muted-foreground"
|
|
|
|
|
onClick={() => void handleForkChat(contextMenu.item.id)}
|
|
|
|
|
disabled={isForkingChat || isItemRunning(contextMenu.item)}
|
|
|
|
|
>
|
|
|
|
|
<GitFork className="h-3.5 w-3.5" />
|
|
|
|
|
Fork
|
|
|
|
|
</button>
|
|
|
|
|
<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={() => openRenameChatDialog(contextMenu.item.id)}
|
|
|
|
|
>
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
Rename
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
|
|
|
|
|
onClick={() => void handleDeleteFromContextMenu()}
|
|
|
|
|
disabled={isDeleteBlocked(contextMenu.item)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
{isRootChat(contextMenu.item) ? "Delete group" : "Delete"}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{isChatSettingsOpen ? (
|
|
|
|
|