Skip to content
Closed
35 changes: 29 additions & 6 deletions studio/frontend/src/features/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
modelType="base"
pairId={pairId}
initialThreadId={baseThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="base" />
<Thread hideComposer={true} hideWelcome={true} />
Expand All @@ -242,6 +243,7 @@ const LoraCompareContent = memo(function LoraCompareContent({
modelType="lora"
pairId={pairId}
initialThreadId={loraThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="lora" />
<Thread hideComposer={true} hideWelcome={true} />
Expand Down Expand Up @@ -343,6 +345,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
modelType="model1"
pairId={pairId}
initialThreadId={model1ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model1" />
<Thread hideComposer={true} hideWelcome={true} />
Expand Down Expand Up @@ -376,6 +379,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
modelType="model2"
pairId={pairId}
initialThreadId={model2ThreadId}
syncActiveThreadId={false}
>
<RegisterCompareHandle name="model2" />
<Thread hideComposer={true} hideWelcome={true} />
Expand Down Expand Up @@ -479,11 +483,19 @@ function TopBarActions({
);
}

function getInitialSingleChatView(): ChatView {
const id = useChatRuntimeStore.getState().activeThreadId;
if (typeof id === "string" && id.length > 0 && !id.startsWith("__LOCALID_")) {
return { mode: "single", threadId: id };
}
return { mode: "single" };
}

export function ChatPage(): ReactElement {
const [view, setView] = useState<ChatView>({
mode: "single",
newThreadNonce: crypto.randomUUID(),
});
// Do not set newThreadNonce here: each /chat mount would run ThreadNewChatSwitch
// and create spurious threads when navigating (e.g. Recipes / Export). New Chat
// explicitly sets a nonce in handleNewThread.
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
const [settingsOpen, setSettingsOpen] = useState(false);
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
Expand Down Expand Up @@ -587,9 +599,20 @@ export function ChatPage(): ReactElement {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(() => {
// Skip if we are already on a fresh unsaved draft with no messages sent.
// Once the user sends a message, append() sets activeThreadId in the store,
// so we check the store to know whether the current draft has been sent.
if (
view.mode === "single" &&
!view.threadId &&
!useChatRuntimeStore.getState().activeThreadId
) {
return;
}

useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
}, []);
}, [view]);
Comment on lines 601 to +615
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The guard added to handleNewThread prevents the "New Chat" action from resetting the composer state if the user is already on an unsaved draft (i.e., no messages sent yet). This means clicking "New Chat" won't clear any typed text or attachments in the current draft, which is a regression in user experience.

Since new threads are no longer persisted until the first message is sent (as per the changes in runtime-provider.tsx), there is no risk of cluttering the database with empty threads by allowing the state to reset. Removing this guard ensures that the "New Chat" button always provides a fresh start as expected.

  const handleNewThread = useCallback(() => {
    useChatRuntimeStore.getState().setActiveThreadId(null);
    setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
  }, []);

const handleNewCompare = useCallback(() => {
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
Expand Down Expand Up @@ -922,7 +945,7 @@ export function ChatPage(): ReactElement {

{view.mode === "single" ? (
<SingleContent
key={view.threadId ?? view.newThreadNonce ?? "new"}
key={view.threadId ?? "single"}
threadId={view.threadId}
newThreadNonce={view.newThreadNonce}
/>
Expand Down
61 changes: 35 additions & 26 deletions studio/frontend/src/features/chat/runtime-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,15 @@ function ThreadHistoryProvider({

async append({ parentId, message }: ExportedMessageRepositoryItem) {
const { remoteId } = await aui.threadListItem().initialize();
// Keep single-chat runtime state in sync once a new chat is first
// persisted. Compare panes intentionally do not write global activeThreadId.
const thread = await db.threads.get(remoteId);
if (thread?.modelType === "base" && !thread.pairId) {
const store = useChatRuntimeStore.getState();
if (store.activeThreadId !== remoteId) {
store.setActiveThreadId(remoteId);
}
}
const content = cloneContent(message.content);
const attachments =
message.role === "user" ? cloneAttachments(message.attachments) : [];
Expand Down Expand Up @@ -658,7 +667,11 @@ function useRuntimeHook(): ReturnType<typeof useLocalRuntime> {

function ThreadAutoSwitch({
threadId,
}: { threadId: string }): ReactElement | null {
syncActiveThreadId = true,
}: {
threadId: string;
syncActiveThreadId?: boolean;
}): ReactElement | null {
const aui = useAui();
const isLoading = useAuiState(({ threads }) => threads.isLoading);
const mainThreadId = useAuiState(({ threads }) => threads.mainThreadId);
Expand All @@ -669,6 +682,13 @@ function ThreadAutoSwitch({
}
}, [aui, isLoading, mainThreadId, threadId]);

useEffect(() => {
if (!syncActiveThreadId || isLoading || mainThreadId !== threadId) {
return;
}
useChatRuntimeStore.getState().setActiveThreadId(threadId);
}, [isLoading, mainThreadId, syncActiveThreadId, threadId]);

return null;
}

Expand All @@ -682,30 +702,10 @@ function ThreadNewChatSwitch({
if (isLoading) {
return;
}

let cancelled = false;
// Clear immediately so the adapter never picks up a stale thread ID
// from a previous chat while we initialize the new one.
// Switch to a fresh local thread without persisting it yet.
// Persistence still happens on first message append.
void aui.threads().switchToNewThread();
useChatRuntimeStore.getState().setActiveThreadId(null);

void (async () => {
try {
aui.threads().switchToNewThread();
const { remoteId } = await aui.threadListItem().initialize();
if (!cancelled) {
useChatRuntimeStore.getState().setActiveThreadId(remoteId);
}
} catch (error) {
if (!cancelled) {
useChatRuntimeStore.getState().setActiveThreadId(null);
}
console.error("Failed to initialize new chat thread", error);
}
})();

return () => {
cancelled = true;
};
}, [aui, isLoading, nonce]);

return null;
Expand Down Expand Up @@ -733,12 +733,14 @@ export function ChatRuntimeProvider({
pairId,
initialThreadId,
newThreadNonce,
syncActiveThreadId = true,
}: {
children: ReactNode;
modelType?: ModelType;
pairId?: string;
initialThreadId?: string;
newThreadNonce?: string;
syncActiveThreadId?: boolean;
}): ReactElement {
const runtime = useRemoteThreadListRuntime({
runtimeHook: useRuntimeHook,
Expand All @@ -754,8 +756,15 @@ export function ChatRuntimeProvider({

return (
<AssistantRuntimeProvider runtime={runtime} aui={aui}>
<ActiveThreadSync enabled={modelType === "base" && !pairId && !newThreadNonce} />
{initialThreadId && <ThreadAutoSwitch threadId={initialThreadId} />}
<ActiveThreadSync
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
/>
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}
syncActiveThreadId={syncActiveThreadId}
/>
)}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />
)}
Expand Down
22 changes: 16 additions & 6 deletions studio/frontend/src/features/chat/thread-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { db, useLiveQuery } from "./db";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import type { ChatView, ThreadRecord } from "./types";

interface SidebarItem {
Expand Down Expand Up @@ -76,12 +77,17 @@ export function ThreadSidebar({
onNewCompare: () => void;
showCompare: boolean;
}) {
const allThreads = useLiveQuery(
() => db.threads.orderBy("createdAt").reverse().toArray(),
[],
);
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
Comment on lines +80 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The useLiveQuery implementation for filtering empty threads uses db.messages.orderBy("threadId").uniqueKeys(). While efficient for small to medium datasets due to the index on threadId, this operation's performance will degrade as the total number of messages in the database grows, as it requires an index scan to find unique keys.

Consider adding a messageCount or hasMessages boolean field to the threads table and updating it during the first message append. This would allow for a much more scalable query directly on the threads table without involving the messages table at all.

Comment on lines +80 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of useLiveQuery to fetch threads could be inefficient if there are many threads stored in the database. It fetches all threads and then filters them in JavaScript. A more performant approach would be to fetch only the threads that have messages directly from the database using where...anyOf.

Suggested change
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = new Set(
(await db.messages.orderBy("threadId").uniqueKeys()) as string[],
);
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived && threadIdsWithMessage.has(t.id));
}, []);
const allThreads = useLiveQuery(async () => {
const threadIdsWithMessage = (await db.messages
.orderBy("threadId")
.uniqueKeys()) as string[];
const threads = await db.threads
.where("id")
.anyOf(threadIdsWithMessage)
.and((t) => !t.archived)
.toArray();
return threads.sort((a, b) => b.createdAt - a.createdAt);
}, []);

const items = groupThreads(allThreads ?? []);
const activeId = view.mode === "single" ? view.threadId : view.pairId;
const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId);
const activeId =
view.mode === "single" ? (view.threadId ?? storeThreadId) : view.pairId;

function viewForItem(item: SidebarItem): ChatView {
return item.type === "single"
Expand All @@ -101,7 +107,11 @@ export function ThreadSidebar({
}
}
if (activeId === item.id) {
onSelect({ mode: "single" });
// Directly set a new view with a nonce rather than going through
// onNewThread(), which may return early if the guard sees no
// threadId and no activeThreadId (after we just cleared it).
useChatRuntimeStore.getState().setActiveThreadId(null);
onSelect({ mode: "single", newThreadNonce: crypto.randomUUID() });
}
}

Expand Down
Loading