Skip to content
Closed
48 changes: 40 additions & 8 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,29 @@ export function ChatPage(): ReactElement {
void ejectModel();
}, [ejectModel]);
const handleNewThread = useCallback(() => {
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
}, []);
void (async () => {
if (view.mode === "single") {
const currentThreadId = view.threadId ?? activeThreadId;
if (!currentThreadId) {
// Already at a fresh/unsaved chat state.
return;
}
try {
const hasMessages = !!(await db.messages
.where("threadId")
.equals(currentThreadId)
.first());
if (!hasMessages) {
return;
}
} catch {
// allow explicit new chat if Dexie fails
}
}
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
})();
}, [activeThreadId, view]);

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 added logic in handleNewThread to check for existing messages is redundant and introduces a UX regression.

  1. Redundancy: The goal of preventing empty threads in the database is already addressed by the changes in ThreadNewChatSwitch (in runtime-provider.tsx), which now avoids calling initialize() until the first message is appended.
  2. UX Regression: This check prevents the "New Chat" button from resetting the composer state (clearing unsent text) when the user is in an unpersisted or empty thread. Users often rely on "New Chat" to quickly clear their current input and start fresh.

Since empty threads are no longer persisted automatically, it is better to allow the "New Chat" action to always proceed, ensuring the UI can be reset consistently.

  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 +954,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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Disabling ActiveThreadSync when newThreadNonce or initialThreadId is present prevents the global store from syncing the activeThreadId once the thread is actually initialized or switched to. For a 'New Chat', the thread is only persisted after the first message is sent; at that point, mainThreadId will be set by the runtime, but the store will remain at null because sync is disabled here. This breaks features that rely on the store's activeThreadId, such as the context usage bar. Keeping sync enabled for all base single-chat views ensures the store stays updated as soon as the runtime identifies the active thread.

Suggested change
enabled={modelType === "base" && !pairId && !newThreadNonce && !initialThreadId}
enabled={modelType === "base" && !pairId}

/>
{initialThreadId && (
<ThreadAutoSwitch
threadId={initialThreadId}
syncActiveThreadId={syncActiveThreadId}
/>
)}
{!initialThreadId && newThreadNonce && (
<ThreadNewChatSwitch nonce={newThreadNonce} />
)}
Expand Down
5 changes: 4 additions & 1 deletion studio/frontend/src/features/chat/thread-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ export function ThreadSidebar({
showCompare: boolean;
}) {
const allThreads = useLiveQuery(
() => db.threads.orderBy("createdAt").reverse().toArray(),
async () => {
const rows = await db.threads.orderBy("createdAt").reverse().toArray();
return rows.filter((t) => !t.archived);

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

Filtering archived threads here makes the identical check inside the groupThreads function (line 39) redundant. While filtering at the query level is more efficient, you should remove the redundant check from groupThreads to maintain code clarity and avoid confusion about where the filtering logic resides.

},
[],
);
const items = groupThreads(allThreads ?? []);
Expand Down
Loading