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,