diff --git a/app/components/chat-list.tsx b/app/components/chat-list.tsx
index 63dc4d5ff30..b2224f12115 100644
--- a/app/components/chat-list.tsx
+++ b/app/components/chat-list.tsx
@@ -1,4 +1,5 @@
import DeleteIcon from "../icons/delete.svg";
+import PinIcon from "../icons/pin.svg";
import styles from "./home.module.scss";
import {
@@ -15,7 +16,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import { Path } from "../constant";
import { MaskAvatar } from "./mask";
import { Mask } from "../store/mask";
-import { useRef, useEffect } from "react";
+import { useRef, useEffect, useMemo } from "react";
import { showConfirm } from "./ui-lib";
import { useMobileScreen } from "../utils";
import clsx from "clsx";
@@ -23,10 +24,12 @@ import clsx from "clsx";
export function ChatItem(props: {
onClick?: () => void;
onDelete?: () => void;
+ onPin?: () => void;
title: string;
count: number;
time: string;
selected: boolean;
+ pinned?: boolean;
id: string;
index: number;
narrow?: boolean;
@@ -50,6 +53,7 @@ export function ChatItem(props: {
[styles["chat-item-selected"]]:
props.selected &&
(currentPath === Path.Chat || currentPath === Path.Home),
+ [styles["chat-item-pinned"]]: props.pinned,
})}
onClick={props.onClick}
ref={(ele) => {
@@ -86,6 +90,22 @@ export function ChatItem(props: {
>
)}
+
{
+ props.onPin?.();
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ title={
+ props.pinned
+ ? Locale.ChatItem.UnpinChat
+ : Locale.ChatItem.PinChat
+ }
+ >
+
+
+
{
@@ -115,6 +135,17 @@ export function ChatList(props: { narrow?: boolean }) {
const navigate = useNavigate();
const isMobileScreen = useMobileScreen();
+ // Sort sessions: pinned first, then by original order
+ const sortedSessions = useMemo(() => {
+ return sessions
+ .map((session, originalIndex) => ({ session, originalIndex }))
+ .sort((a, b) => {
+ const aPinned = a.session.pinned ? 1 : 0;
+ const bPinned = b.session.pinned ? 1 : 0;
+ return bPinned - aPinned;
+ });
+ }, [sessions]);
+
const onDragEnd: OnDragEndResponder = (result) => {
const { destination, source } = result;
if (!destination) {
@@ -128,7 +159,10 @@ export function ChatList(props: { narrow?: boolean }) {
return;
}
- moveSession(source.index, destination.index);
+ // Map sorted index back to original index for moveSession
+ const fromOriginal = sortedSessions[source.index].originalIndex;
+ const toOriginal = sortedSessions[destination.index].originalIndex;
+ moveSession(fromOriginal, toOriginal);
};
return (
@@ -140,29 +174,33 @@ export function ChatList(props: { narrow?: boolean }) {
ref={provided.innerRef}
{...provided.droppableProps}
>
- {sessions.map((item, i) => (
+ {sortedSessions.map(({ session, originalIndex }, i) => (
{
navigate(Path.Chat);
- selectSession(i);
+ selectSession(originalIndex);
+ }}
+ onPin={() => {
+ chatStore.togglePin(originalIndex);
}}
onDelete={async () => {
if (
(!props.narrow && !isMobileScreen) ||
(await showConfirm(Locale.Home.DeleteChat))
) {
- chatStore.deleteSession(i);
+ chatStore.deleteSession(originalIndex);
}
}}
narrow={props.narrow}
- mask={item.mask}
+ mask={session.mask}
/>
))}
{provided.placeholder}
diff --git a/app/components/home.module.scss b/app/components/home.module.scss
index 381b6a9b951..72c10ac7914 100644
--- a/app/components/home.module.scss
+++ b/app/components/home.module.scss
@@ -205,24 +205,45 @@
animation: slide-in ease 0.3s;
}
+.chat-item-pin,
.chat-item-delete {
position: absolute;
top: 0;
- right: 0;
transition: all ease 0.3s;
opacity: 0;
cursor: pointer;
}
+.chat-item-pin {
+ right: 20px;
+}
+
+.chat-item-delete {
+ right: 0;
+}
+
+.chat-item:hover > .chat-item-pin,
.chat-item:hover > .chat-item-delete {
opacity: 0.5;
transform: translateX(-4px);
}
+.chat-item:hover > .chat-item-pin:hover,
.chat-item:hover > .chat-item-delete:hover {
opacity: 1;
}
+.chat-item-pinned {
+ .chat-item-pin {
+ opacity: 0.8;
+ color: var(--primary);
+ }
+
+ &:hover .chat-item-pin {
+ opacity: 1;
+ }
+}
+
.chat-item-info {
display: flex;
justify-content: space-between;
diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx
index 56bc5bb4327..8dad809f373 100644
--- a/app/components/sidebar.tsx
+++ b/app/components/sidebar.tsx
@@ -12,6 +12,7 @@ import MaskIcon from "../icons/mask.svg";
import McpIcon from "../icons/mcp.svg";
import DragIcon from "../icons/drag.svg";
import DiscoveryIcon from "../icons/discovery.svg";
+import NewWindowIcon from "../icons/new-window.svg";
import Locale from "../locales";
@@ -32,6 +33,7 @@ import dynamic from "next/dynamic";
import { Selector, showConfirm } from "./ui-lib";
import clsx from "clsx";
import { isMcpEnabled } from "../mcp/actions";
+import { openNewChatWindow } from "../utils/window";
const DISCOVERY = [
{ name: Locale.Plugin.Name, path: Path.Plugins },
@@ -48,6 +50,12 @@ export function useHotKey() {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
+ if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === "n") {
+ // Ctrl+Shift+N: open a new app window with its own configuration (#4886)
+ e.preventDefault();
+ openNewChatWindow();
+ return;
+ }
if (e.altKey || e.ctrlKey) {
if (e.key === "ArrowUp") {
chatStore.nextSession(-1);
@@ -232,6 +240,11 @@ export function SideBar(props: { className?: string }) {
const config = useAppConfig();
const chatStore = useChatStore();
const [mcpEnabled, setMcpEnabled] = useState(false);
+ const [isApp, setIsApp] = useState(false);
+
+ useEffect(() => {
+ setIsApp(!!window.__TAURI__);
+ }, []);
useEffect(() => {
// 检查 MCP 是否启用
@@ -327,6 +340,16 @@ export function SideBar(props: { className?: string }) {
}}
/>
+ {isApp && (
+
+ }
+ onClick={() => openNewChatWindow()}
+ shadow
+ />
+
+ )}
;
invoke(command: string, payload?: Record): Promise;
+ window: {
+ getAll(): { label: string }[];
+ WebviewWindow: new (
+ label: string,
+ options?: Record,
+ ) => {
+ once(event: string, handler: (event: unknown) => void): void;
+ };
+ };
dialog: {
save(options?: Record): Promise;
};
diff --git a/app/icons/new-window.svg b/app/icons/new-window.svg
new file mode 100644
index 00000000000..d69be3c65bc
--- /dev/null
+++ b/app/icons/new-window.svg
@@ -0,0 +1 @@
+
diff --git a/app/locales/cn.ts b/app/locales/cn.ts
index 2cb7dd1e535..5fdd21193a1 100644
--- a/app/locales/cn.ts
+++ b/app/locales/cn.ts
@@ -31,6 +31,8 @@ const cn = {
},
ChatItem: {
ChatItemCount: (count: number) => `${count} 条对话`,
+ PinChat: "固定此对话",
+ UnpinChat: "取消固定",
},
Chat: {
SubTitle: (count: number) => `共 ${count} 条对话`,
@@ -795,6 +797,7 @@ const cn = {
Import: "导入",
Sync: "同步",
Config: "配置",
+ NewWindow: "新窗口",
},
Exporter: {
Description: {
diff --git a/app/locales/en.ts b/app/locales/en.ts
index a6d1919045c..769b5b3812d 100644
--- a/app/locales/en.ts
+++ b/app/locales/en.ts
@@ -32,6 +32,8 @@ const en: LocaleType = {
},
ChatItem: {
ChatItemCount: (count: number) => `${count} messages`,
+ PinChat: "Pin this chat",
+ UnpinChat: "Unpin this chat",
},
Chat: {
SubTitle: (count: number) => `${count} messages`,
@@ -801,6 +803,7 @@ const en: LocaleType = {
Import: "Import",
Sync: "Sync",
Config: "Config",
+ NewWindow: "New Window",
},
Exporter: {
Description: {
diff --git a/app/store/chat.ts b/app/store/chat.ts
index 87c1a8beba0..418d2d9b61a 100644
--- a/app/store/chat.ts
+++ b/app/store/chat.ts
@@ -93,6 +93,8 @@ export interface ChatSession {
clearContextIndex?: number;
mask: Mask;
+
+ pinned?: boolean;
}
export const DEFAULT_TOPIC = Locale.Store.DefaultTopic;
@@ -116,6 +118,8 @@ function createEmptySession(): ChatSession {
lastSummarizeIndex: 0,
mask: createEmptyMask(),
+
+ pinned: false,
};
}
@@ -304,6 +308,16 @@ export const useChatStore = createPersistStore(
});
},
+ togglePin(index: number) {
+ set((state) => {
+ const sessions = [...state.sessions];
+ const session = { ...sessions[index] };
+ session.pinned = !session.pinned;
+ sessions[index] = session;
+ return { sessions };
+ });
+ },
+
newSession(mask?: Mask) {
const session = createEmptySession();
diff --git a/app/utils/indexedDB-storage.ts b/app/utils/indexedDB-storage.ts
index 51417e9f3d9..6f93e079e50 100644
--- a/app/utils/indexedDB-storage.ts
+++ b/app/utils/indexedDB-storage.ts
@@ -1,43 +1,70 @@
import { StateStorage } from "zustand/middleware";
-import { get, set, del, clear } from "idb-keyval";
+import { get, set, del, clear, keys } from "idb-keyval";
import { safeLocalStorage } from "@/app/utils";
+import { getStoragePrefix, isMainWindow } from "@/app/utils/window";
const localStorage = safeLocalStorage();
+// Each app window gets its own storage namespace (see #4886) so that every
+// window can hold an independent server configuration. The main window keeps
+// the historical unprefixed keys, so existing data is preserved.
+const storagePrefix = getStoragePrefix();
+
+function prefixed(name: string): string {
+ return storagePrefix + name;
+}
+
class IndexedDBStorage implements StateStorage {
public async getItem(name: string): Promise {
+ const key = prefixed(name);
try {
- const value = (await get(name)) || localStorage.getItem(name);
+ const value = (await get(key)) || localStorage.getItem(key);
return value;
} catch (error) {
- return localStorage.getItem(name);
+ return localStorage.getItem(key);
}
}
public async setItem(name: string, value: string): Promise {
+ const key = prefixed(name);
try {
const _value = JSON.parse(value);
if (!_value?.state?._hasHydrated) {
console.warn("skip setItem", name);
return;
}
- await set(name, value);
+ await set(key, value);
} catch (error) {
- localStorage.setItem(name, value);
+ localStorage.setItem(key, value);
}
}
public async removeItem(name: string): Promise {
+ const key = prefixed(name);
try {
- await del(name);
+ await del(key);
} catch (error) {
- localStorage.removeItem(name);
+ localStorage.removeItem(key);
}
}
public async clear(): Promise {
try {
- await clear();
+ if (isMainWindow()) {
+ // The main window owns the global namespace: a full reset also drops
+ // the namespaced data of secondary windows.
+ await clear();
+ } else {
+ // Secondary windows only drop their own namespace.
+ const allKeys = await keys();
+ await Promise.all(
+ allKeys
+ .filter(
+ (key) => typeof key === "string" && key.startsWith(storagePrefix),
+ )
+ .map((key) => del(key)),
+ );
+ }
} catch (error) {
localStorage.clear();
}
diff --git a/app/utils/window.ts b/app/utils/window.ts
new file mode 100644
index 00000000000..978c6df32d3
--- /dev/null
+++ b/app/utils/window.ts
@@ -0,0 +1,110 @@
+/**
+ * Multi-window support for the Tauri desktop app.
+ *
+ * Every window carries its own label (the main window is "main", additional
+ * windows are "window-N"). The label is passed to the frontend through the
+ * `window_label` query parameter so it can be read synchronously at module
+ * init time — this is what allows each window to namespace its persisted
+ * stores (see indexedDB-storage.ts) and therefore keep an independent
+ * server configuration, as requested in #4886.
+ *
+ * This module intentionally has no app-internal imports to avoid circular
+ * dependencies with the storage layer.
+ */
+
+const WINDOW_LABEL_PARAM = "window_label";
+const MAIN_WINDOW_LABEL = "main";
+const WINDOW_LABEL_PREFIX = "window-";
+// Matches Tauri's label constraints and keeps the value safe for storage keys.
+const LABEL_SAFE_CHARS = /[^a-zA-Z0-9_-]/g;
+
+function sanitizeWindowLabel(label: string): string {
+ return label.replace(LABEL_SAFE_CHARS, "");
+}
+
+/**
+ * Returns the label of the current window. Falls back to "main" when the
+ * parameter is absent (web builds, first window) or unreadable.
+ */
+export function getWindowLabel(): string {
+ if (typeof window === "undefined") {
+ return MAIN_WINDOW_LABEL;
+ }
+ try {
+ const label = new URLSearchParams(window.location.search).get(
+ WINDOW_LABEL_PARAM,
+ );
+ if (label) {
+ return sanitizeWindowLabel(label);
+ }
+ } catch (e) {
+ console.error("[Window] failed to read window label:", e);
+ }
+ return MAIN_WINDOW_LABEL;
+}
+
+export function isMainWindow(): boolean {
+ return getWindowLabel() === MAIN_WINDOW_LABEL;
+}
+
+/**
+ * Prefix applied to every persisted store key. The main window keeps the
+ * historical unprefixed keys so existing user data is untouched; secondary
+ * windows read/write their own isolated copies.
+ */
+export function getStoragePrefix(): string {
+ const label = getWindowLabel();
+ return label === MAIN_WINDOW_LABEL ? "" : `${label}::`;
+}
+
+/**
+ * Opens a new app window with an independent configuration namespace.
+ * Labels are recycled (window-2, window-3, ...): the smallest free index is
+ * used, so reopening "the second window" after a restart restores the same
+ * settings it had before.
+ *
+ * No-op outside the Tauri app.
+ */
+export async function openNewChatWindow(): Promise {
+ const tauri = typeof window !== "undefined" ? window.__TAURI__ : undefined;
+ const tauriWindow = tauri?.window;
+ if (!tauriWindow?.WebviewWindow) {
+ return;
+ }
+
+ let label: string;
+ let index = 0;
+ try {
+ const existing = new Set(
+ (tauriWindow.getAll?.() ?? []).map((w) => w.label),
+ );
+ existing.add(MAIN_WINDOW_LABEL);
+ index = 2;
+ label = `${WINDOW_LABEL_PREFIX}${index}`;
+ while (existing.has(label)) {
+ index += 1;
+ label = `${WINDOW_LABEL_PREFIX}${index}`;
+ }
+ } catch (e) {
+ // getAll() unavailable (older allowlist) — fall back to a unique label.
+ console.warn("[Window] failed to enumerate windows:", e);
+ label = `${WINDOW_LABEL_PREFIX}${Date.now()}`;
+ }
+
+ const webview = new tauriWindow.WebviewWindow(label, {
+ url: `/?${WINDOW_LABEL_PARAM}=${label}`,
+ title: index > 0 ? `NextChat #${index}` : "NextChat",
+ width: 960,
+ height: 600,
+ resizable: true,
+ fullscreen: false,
+ });
+
+ await new Promise((resolve) => {
+ webview.once("tauri://created", () => resolve());
+ webview.once("tauri://error", (e) => {
+ console.error("[Window] failed to create window:", e);
+ resolve();
+ });
+ });
+}
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 8a11c3b6f98..2aedf83e205 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -25,6 +25,7 @@ tauri = { version = "1.5.4", features = [ "http-all",
"shell-open",
"updater",
"window-close",
+ "window-create",
"window-hide",
"window-maximize",
"window-minimize",
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index bfa82b298df..fbb33a83332 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -34,6 +34,7 @@
"window": {
"all": false,
"close": true,
+ "create": true,
"hide": true,
"maximize": true,
"minimize": true,