Skip to content
Merged
48 changes: 41 additions & 7 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Q: Does this work for Side by Side comparison one as well?

@Imagineer99 Imagineer99 Apr 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

nope, this only restores the initial single-chat view from activeThreadId, compare uses pairId

@Imagineer99 Imagineer99 Apr 6, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

hm will disable compare panes from writing global activeThreadId so state won’t bleed across

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,31 @@ 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) {
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
return;
}
try {
const messageCount = await db.messages
.where("threadId")
.equals(currentThreadId)
.limit(1)
.count();
if (messageCount === 0) {
return;
}
Comment thread
Imagineer99 marked this conversation as resolved.
Outdated
} catch {
// allow explicit new chat if Dexie fails
}
}
useChatRuntimeStore.getState().setActiveThreadId(null);
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
})();
}, [activeThreadId, view]);
const handleNewCompare = useCallback(() => {
setView({ mode: "compare", pairId: crypto.randomUUID() });
// Clear activeThreadId so compare panes do not inherit the single-chat
Expand Down
50 changes: 27 additions & 23 deletions studio/frontend/src/features/chat/runtime-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,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 +673,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 @@ -683,29 +694,13 @@ function ThreadNewChatSwitch({
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.
// from a previous chat. Do not call initialize() here — that persisted an
// empty "New Chat" row on every New Chat click. Persistence runs on first
// real message append (history.append → threadListItem.initialize).
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;
};
void aui.threads().switchToNewThread();
Comment thread
Imagineer99 marked this conversation as resolved.
Outdated
}, [aui, isLoading, nonce]);

return null;
Expand Down Expand Up @@ -733,12 +728,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 +751,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
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);
},
[],
);
const items = groupThreads(allThreads ?? []);
Expand Down