Add chat thread forking
TestFlight / Build and upload (push) Successful in 1m52s

This commit is contained in:
2026-08-16 17:42:39 -07:00
parent 42022bf055
commit eb2b0d3ca0
17 changed files with 1410 additions and 244 deletions
+382 -139
View File
@@ -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 ? (
@@ -10,6 +10,7 @@ type Props = {
messages: Message[];
isLoading: boolean;
isSending: boolean;
onMessageContextMenu?: (event: MouseEvent, messageId: string) => void;
};
type ToolLogMetadata = {
@@ -395,7 +396,7 @@ function ToolCallStack({
);
}
export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageContextMenu }: Props) {
const hasPendingAssistant = messages.some((message) => message.id.startsWith("temp-assistant-") && message.content.trim().length === 0);
const renderItems = useMemo(() => buildMessageRenderItems(messages), [messages]);
const toolCallMessageIDs = useMemo(() => getToolCallMessageIDs(messages), [messages]);
@@ -467,6 +468,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
? (event) => onMessageContextMenu(event, message.id)
: undefined
}
>
{attachments.length ? <ChatAttachmentList attachments={attachments} tone={isUser ? "user" : "assistant"} /> : null}
{isPendingAssistant ? (
+12
View File
@@ -1,6 +1,8 @@
export type ChatSummary = {
id: string;
title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string;
updatedAt: string;
starred: boolean;
@@ -58,6 +60,8 @@ export type ToolCallEvent = {
export type ChatDetail = {
id: string;
title: string | null;
parentChatId: string | null;
titleGenerationPending: boolean;
createdAt: string;
updatedAt: string;
starred: boolean;
@@ -291,6 +295,14 @@ export async function getChat(chatId: string) {
return data.chat;
}
export async function forkChat(chatId: string, messageId?: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}/fork`, {
method: "POST",
body: JSON.stringify(messageId ? { messageId } : {}),
});
return data.chat;
}
export async function updateChatTitle(chatId: string, title: string) {
const data = await api<{ chat: ChatSummary }>(`/v1/chats/${chatId}`, {
method: "PATCH",
+82
View File
@@ -0,0 +1,82 @@
export type ForkGroupingItem = {
kind: "chat" | "search";
id: string;
parentChatId: string | null;
};
type ChatTitleState = {
title: string | null;
titleGenerationPending: boolean;
};
type ForkPresentationItem = ForkGroupingItem & {
updatedAt: string;
starred: boolean;
starredAt: string | null;
};
export function groupForkedChatItems<T extends ForkGroupingItem>(items: T[]): T[][] {
const groups = new Map<string, { firstIndex: number; items: Array<{ item: T; index: number }> }>();
items.forEach((item, index) => {
const groupKey = item.kind === "chat" ? `chat:${item.parentChatId ?? item.id}` : `search:${item.id}`;
const group = groups.get(groupKey);
if (group) {
group.items.push({ item, index });
return;
}
groups.set(groupKey, { firstIndex: index, items: [{ item, index }] });
});
return [...groups.values()]
.sort((a, b) => a.firstIndex - b.firstIndex)
.map((group) =>
group.items
.sort((a, b) => {
const aIsRoot = a.item.kind === "chat" && a.item.parentChatId === null;
const bIsRoot = b.item.kind === "chat" && b.item.parentChatId === null;
if (aIsRoot !== bIsRoot) return aIsRoot ? -1 : 1;
return a.index - b.index;
})
.map(({ item }) => item)
);
}
export function filterSidebarItemsWithForkGroups<T extends ForkGroupingItem>(items: T[], matches: (item: T) => boolean): T[] {
return groupForkedChatItems(items)
.flatMap((group) => {
const matchingItems = group.filter(matches);
if (!matchingItems.length) return [];
const root = group.find((item) => item.kind === "chat" && item.parentChatId === null);
if (!root || matchingItems.includes(root)) return group;
return group.filter((item) => item === root || matchingItems.includes(item));
});
}
export function getForkGroupPresentation<T extends ForkPresentationItem>(groupItems: T[], allItems: T[]) {
const firstItem = groupItems[0];
if (!firstItem) return null;
const rootId = firstItem.kind === "chat" ? firstItem.parentChatId ?? firstItem.id : null;
const familyItems = rootId
? allItems.filter((item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId))
: groupItems;
const updatedAt = familyItems.reduce(
(newest, item) => (new Date(item.updatedAt).getTime() > new Date(newest).getTime() ? item.updatedAt : newest),
firstItem.updatedAt
);
const starOwner = rootId
? familyItems.find((item) => item.kind === "chat" && item.parentChatId === null)
: firstItem;
return {
updatedAt,
starred: starOwner?.starred ?? false,
starredAt: starOwner?.starredAt ?? null,
};
}
export function shouldRequestChatTitle(chat: ChatTitleState | null | undefined, isRequestInFlight: boolean) {
if (!chat || isRequestInFlight) return false;
return chat.titleGenerationPending || !chat.title?.trim();
}
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
filterSidebarItemsWithForkGroups,
getForkGroupPresentation,
groupForkedChatItems,
shouldRequestChatTitle,
} from "../src/lib/chat-forking.ts";
const root = { kind: "chat", id: "root", parentChatId: null };
const firstFork = { kind: "chat", id: "first-fork", parentChatId: "root" };
const nestedFork = { kind: "chat", id: "nested-fork", parentChatId: "root" };
const search = { kind: "search", id: "search", parentChatId: null };
test("fork groups are positioned by their newest member and always render root first", () => {
assert.deepEqual(groupForkedChatItems([nestedFork, search, root, firstFork]), [
[root, nestedFork, firstFork],
[search],
]);
});
test("sidebar filtering keeps a matching fork with its root", () => {
assert.deepEqual(
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "nested-fork"),
[root, nestedFork]
);
});
test("sidebar filtering keeps all children when their root matches", () => {
assert.deepEqual(
filterSidebarItemsWithForkGroups([search, root, firstFork, nestedFork], (item) => item.id === "root"),
[root, firstFork, nestedFork]
);
});
test("a filtered family keeps its full-family date and root-owned star state", () => {
const fullFamily = [
{ ...root, updatedAt: "2026-08-10T00:00:00.000Z", starred: true, starredAt: "2026-08-15T00:00:00.000Z" },
{ ...firstFork, updatedAt: "2026-08-11T00:00:00.000Z", starred: false, starredAt: null },
{ ...nestedFork, updatedAt: "2026-08-16T00:00:00.000Z", starred: false, starredAt: null },
];
const filteredFamily = [fullFamily[0], fullFamily[1]];
assert.deepEqual(getForkGroupPresentation(filteredFamily, fullFamily), {
updatedAt: "2026-08-16T00:00:00.000Z",
starred: true,
starredAt: "2026-08-15T00:00:00.000Z",
});
});
test("a fork placeholder title is replaced on its first submitted prompt", () => {
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, false), true);
assert.equal(shouldRequestChatTitle({ title: "Generated title", titleGenerationPending: false }, false), false);
});
test("title generation remains compatible with untitled chats and deduplicates in-flight requests", () => {
assert.equal(shouldRequestChatTitle({ title: null, titleGenerationPending: false }, false), true);
assert.equal(shouldRequestChatTitle({ title: "Fork of original", titleGenerationPending: true }, true), false);
});
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-composer.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/chat-forking.ts","./src/lib/chat-model-selection.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}