fix(ui/chat): resolve chat routes at render time so navigation works under server_root_path

CHAT_ROUTES was built once at module load via migratedHref, capturing an
empty server root path before the UI-config bootstrap sets it. Under
SERVER_ROOT_PATH every chat route came out unprefixed, so router.push
hard-navigated to a 404; during a send that page unload also aborted the
streaming request, so the first message of a new conversation never
rendered and only a second send appeared to work. Compute the routes at
render time via getChatRoutes(), update the send URL with a shallow
history.pushState instead of a router navigation, and source the active
conversation id from the hook's local state so it propagates without a
router round-trip
This commit is contained in:
Yuneng Jiang 2026-07-15 13:48:27 -07:00
parent be658d5d29
commit 2b9b681e12
No known key found for this signature in database
5 changed files with 77 additions and 29 deletions

View file

@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import MessageManager from "@/components/molecules/message_manager";
import { useRouter } from "next/navigation";
import { useChatShell } from "@/contexts/ChatShellContext";
import { CHAT_ROUTES } from "@/components/chat/ChatShell";
import { getChatRoutes } from "@/components/chat/ChatShell";
import ChatMessages from "@/components/chat/ChatMessages";
import MCPConnectPicker from "@/components/chat/MCPConnectPicker";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
@ -87,7 +87,7 @@ export default function ChatConversationPage() {
const streamScrollLock = useRef<number | null>(null);
useEffect(() => {
if (staleId) router.replace(CHAT_ROUTES.chats);
if (staleId) router.replace(getChatRoutes().chats);
}, [staleId, router]);
// Load models
@ -140,7 +140,7 @@ export default function ChatConversationPage() {
if (!convId) {
convId = createConversation(model);
setResponsesSessionId(null); // new conversation starts a fresh session
router.push(`${CHAT_ROUTES.chats}?id=${convId}`);
window.history.pushState(null, "", `${window.location.pathname}?id=${convId}`);
}
appendMessage(convId, { role: "user", content: trimmed });
@ -248,7 +248,6 @@ export default function ChatConversationPage() {
createConversation,
appendMessage,
updateLastAssistantMessage,
router,
isStreaming,
responsesSessionId,
],
@ -529,7 +528,7 @@ export default function ChatConversationPage() {
Chat with 100+ LLMs + MCP tools; authenticate once, use them here.{" "}
<Button
variant="link"
onClick={() => router.push(CHAT_ROUTES.integrations)}
onClick={() => router.push(getChatRoutes().integrations)}
className="h-auto p-0 text-sm font-medium"
>
Open Integrations -&gt;

View file

@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// Regression for the chat sidebar / first-message navigation under SERVER_ROOT_PATH.
// getChatRoutes() must read the server root path at call time. The previous
// module-level `CHAT_ROUTES` captured it once at import, before the UI-config
// bootstrap runs setServerRootPath, so every chat route was permanently
// unprefixed and router.push() navigated to a 404 (which, mid-stream, also
// aborted the first message). These tests deliberately apply the root path
// AFTER importing the module so a frozen-at-import implementation fails.
describe("getChatRoutes under server_root_path", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("NODE_ENV", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("reflects a server root path applied after the module is loaded", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
setServerRootPath("/gw");
const routes = getChatRoutes();
expect(routes.chats).toBe("/gw/ui/chat");
expect(routes.integrations).toBe("/gw/ui/chat/integrations");
expect(routes.credentials).toBe("/gw/ui/chat/credentials");
expect(routes.apiKeys).toBe("/gw/ui/chat/api-keys");
expect(routes.usage).toBe("/gw/ui/chat/usage");
});
it("builds /ui-rooted paths when no server root path is set", async () => {
const { getChatRoutes } = await import("./ChatShell");
const { setServerRootPath } = await import("@/lib/serverRootPath");
setServerRootPath("/");
expect(getChatRoutes().chats).toBe("/ui/chat");
expect(getChatRoutes().integrations).toBe("/ui/chat/integrations");
});
});

View file

@ -9,14 +9,16 @@ import { migratedHref } from "@/utils/migratedPages";
import { useChatShell } from "@/contexts/ChatShellContext";
import ConversationList from "./ConversationList";
const CHAT_BASE = migratedHref("chat");
export const CHAT_ROUTES = {
chats: CHAT_BASE,
integrations: `${CHAT_BASE}/integrations`,
credentials: `${CHAT_BASE}/credentials`,
apiKeys: `${CHAT_BASE}/api-keys`,
usage: `${CHAT_BASE}/usage`,
};
export function getChatRoutes() {
const base = migratedHref("chat");
return {
chats: base,
integrations: `${base}/integrations`,
credentials: `${base}/credentials`,
apiKeys: `${base}/api-keys`,
usage: `${base}/usage`,
};
}
function stripTrailingSlash(path: string): string {
return path.length > 1 ? path.replace(/\/+$/, "") : path;
@ -54,7 +56,8 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
const pathname = stripTrailingSlash(usePathname() ?? "");
const { conversations, activeConversationId, deleteConversation, renameConversation } = useChatShell();
const isChatsRoute = pathname === CHAT_ROUTES.chats;
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
return (
<div className="flex h-full w-full flex-col bg-background overflow-hidden">
@ -73,7 +76,7 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
<div className="flex flex-1 min-h-0 overflow-hidden">
<div className="shrink-0 bg-sidebar border-sidebar-border border-r flex flex-col overflow-hidden w-[260px]">
<div className="px-2 pt-3 pb-1 shrink-0">
<Button onClick={() => router.push(CHAT_ROUTES.chats)} className="w-full justify-start gap-2.5">
<Button onClick={() => router.push(routes.chats)} className="w-full justify-start gap-2.5">
<Plus className="h-4 w-4" />
New Chat
</Button>
@ -85,32 +88,32 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
<NavItem
icon={<MessageSquare className="h-4 w-4" />}
label="Chats"
onClick={() => router.push(CHAT_ROUTES.chats)}
onClick={() => router.push(routes.chats)}
active={isChatsRoute}
/>
<NavItem
icon={<LayoutGrid className="h-4 w-4" />}
label="Integrations"
onClick={() => router.push(CHAT_ROUTES.integrations)}
active={pathname === CHAT_ROUTES.integrations}
onClick={() => router.push(routes.integrations)}
active={pathname === routes.integrations}
/>
<NavItem
icon={<KeyRound className="h-4 w-4" />}
label="Credentials"
onClick={() => router.push(CHAT_ROUTES.credentials)}
active={pathname === CHAT_ROUTES.credentials}
onClick={() => router.push(routes.credentials)}
active={pathname === routes.credentials}
/>
<NavItem
icon={<Lock className="h-4 w-4" />}
label="API Keys"
onClick={() => router.push(CHAT_ROUTES.apiKeys)}
active={pathname === CHAT_ROUTES.apiKeys}
onClick={() => router.push(routes.apiKeys)}
active={pathname === routes.apiKeys}
/>
<NavItem
icon={<BarChart3 className="h-4 w-4" />}
label="Usage"
onClick={() => router.push(CHAT_ROUTES.usage)}
active={pathname === CHAT_ROUTES.usage}
onClick={() => router.push(routes.usage)}
active={pathname === routes.usage}
/>
</div>
@ -120,10 +123,10 @@ const ChatShell: React.FC<ChatShellProps> = ({ children }) => {
<ConversationList
conversations={conversations}
activeConversationId={activeConversationId}
onSelect={(id) => router.push(`${CHAT_ROUTES.chats}?id=${id}`)}
onSelect={(id) => router.push(`${routes.chats}?id=${id}`)}
onDelete={(id) => {
deleteConversation(id);
if (id === activeConversationId) router.push(CHAT_ROUTES.chats);
if (id === activeConversationId) router.push(routes.chats);
}}
onRename={renameConversation}
/>

View file

@ -52,6 +52,7 @@ export function useChatHistory(
): {
conversations: Conversation[];
activeConversation: Conversation | null;
currentActiveId: string | null;
storageUnavailable: boolean;
staleId: boolean;
createConversation: (model: string) => string;
@ -208,6 +209,7 @@ export function useChatHistory(
return {
conversations,
activeConversation,
currentActiveId,
storageUnavailable,
staleId,
createConversation,

View file

@ -57,12 +57,13 @@ export function ChatShellProvider({
children,
}: ChatShellProviderProps) {
const searchParams = useSearchParams();
const activeConversationId = searchParams.get("id");
const urlConversationId = searchParams.get("id");
const [selectedMCPServers, setSelectedMCPServers] = useState<string[]>([]);
const {
conversations,
activeConversation,
currentActiveId,
storageUnavailable,
staleId,
createConversation,
@ -71,7 +72,7 @@ export function ChatShellProvider({
truncateFromMessage,
deleteConversation,
renameConversation,
} = useChatHistory(activeConversationId, userId);
} = useChatHistory(urlConversationId, userId);
return (
<ChatShellContext.Provider
@ -85,7 +86,7 @@ export function ChatShellProvider({
setSelectedMCPServers,
conversations,
activeConversation,
activeConversationId,
activeConversationId: currentActiveId,
storageUnavailable,
staleId,
createConversation,