diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 6ca6f137ad..813edbb4a9 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -32,6 +32,7 @@ import { STORAGE_KEY_THEME_COLOR, STORAGE_KEY_ZOOM_LEVEL, STORAGE_KEY_WELCOME_QUICK_ACTIONS, + STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, STORAGE_KEY_UI_FONT, STORAGE_KEY_UI_FONT_CUSTOM, STORAGE_KEY_UI_FONT_STACK, @@ -117,6 +118,9 @@ type AppearanceContextValue = { /** 新会话欢迎页是否显示「模式选择区域」(QuickActions 快捷卡片),默认开启 */ showWelcomeQuickActions: boolean setShowWelcomeQuickActions: (on: boolean) => void + /** 打开的会话是否各占一个标签页(关闭预览标签页的「就地替换」),默认关闭 */ + keepOpenedConversations: boolean + setKeepOpenedConversations: (on: boolean) => void /** 界面字体(普通组件,驱动 --font-sans) */ uiFont: FontSelection setUiFont: (id: string, custom?: string) => void @@ -370,6 +374,13 @@ export function AppearanceProvider({ const [showWelcomeQuickActions, setShowWelcomeQuickActionsState] = useState(() => readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true)) + // 打开的会话是否保留各自的标签页:默认关闭,键缺失即回退为预览标签页的历史行为。 + // 真正消费它的是 tab store(在 openTab 里直接读同一个键),这里只是设置面板的回显态。 + const [keepOpenedConversations, setKeepOpenedConversationsState] = + useState(() => + readBool(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, false) + ) + // 字体偏好的初始值从 localStorage 读 id/custom(视觉已由 inline 脚本就位, // 这里只是回填选中态,不会造成闪烁)。 const [uiFont, setUiFontState] = useState(() => @@ -519,6 +530,11 @@ export function AppearanceProvider({ persist(STORAGE_KEY_WELCOME_QUICK_ACTIONS, on ? "1" : "0") }, []) + const setKeepOpenedConversations = useCallback((on: boolean) => { + setKeepOpenedConversationsState(on) + persist(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, on ? "1" : "0") + }, []) + const setUiFont = useCallback((id: string, custom = "") => { setUiFontState({ id, custom }) const stack = resolveFontStack(id, custom, "sans") @@ -955,6 +971,11 @@ export function AppearanceProvider({ readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true) ) } + if (e.key === STORAGE_KEY_KEEP_OPENED_CONVERSATIONS) { + setKeepOpenedConversationsState( + readBool(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, false) + ) + } if (e.key && FONT_KEYS.has(e.key)) { rehydrateFonts() } @@ -1059,6 +1080,8 @@ export function AppearanceProvider({ setZoomLevel, showWelcomeQuickActions, setShowWelcomeQuickActions, + keepOpenedConversations, + setKeepOpenedConversations, uiFont, setUiFont, editorFont, diff --git a/src/components/settings/appearance-settings.tsx b/src/components/settings/appearance-settings.tsx index c5b9d00d8e..cd0f547959 100644 --- a/src/components/settings/appearance-settings.tsx +++ b/src/components/settings/appearance-settings.tsx @@ -1,6 +1,13 @@ "use client" -import { LayoutGrid, Monitor, Moon, Sun, Type } from "lucide-react" +import { + LayoutGrid, + Monitor, + Moon, + PanelsTopLeft, + Sun, + Type, +} from "lucide-react" import { useTranslations } from "next-intl" import { useTheme } from "next-themes" import { ScrollArea } from "@/components/ui/scroll-area" @@ -16,6 +23,7 @@ import { useThemeColor, useZoomLevel, useWelcomeQuickActions, + useKeepOpenedConversations, } from "@/hooks/use-appearance" import { cn } from "@/lib/utils" import { @@ -40,6 +48,8 @@ export function AppearanceSettings() { const { zoomLevel, setZoomLevel } = useZoomLevel() const { showWelcomeQuickActions, setShowWelcomeQuickActions } = useWelcomeQuickActions() + const { keepOpenedConversations, setKeepOpenedConversations } = + useKeepOpenedConversations() const resolvedThemeLabel = resolvedTheme === "dark" @@ -214,6 +224,30 @@ export function AppearanceSettings() { {/* ===== Workspace background ===== */} + {/* ===== Conversation tabs ===== */} +
+
+ +

+ {t("conversationTabs.sectionTitle")} +

+
+ +

+ {t("conversationTabs.sectionDescription")} +

+ + +
+ {/* ===== New conversation — mode selection area ===== */}
diff --git a/src/contexts/tab-context.test.tsx b/src/contexts/tab-context.test.tsx index d0587b4512..fdccfd9584 100644 --- a/src/contexts/tab-context.test.tsx +++ b/src/contexts/tab-context.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen, waitFor } from "@testing-library/react" import { useEffect } from "react" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { TabProvider, useTabContext } from "@/contexts/tab-context" import { CONVERSATION_CHANGED_EVENT, TABS_CHANGED_EVENT } from "@/lib/types" import type { @@ -25,6 +25,7 @@ import { type OpenedDraftTarget, } from "@/stores/tab-store" import { leafIds } from "@/lib/tab-group-layout" +import { STORAGE_KEY_KEEP_OPENED_CONVERSATIONS } from "@/lib/appearance-script" import { buildNewConversationDraftStorageKey, loadMessageInputDraftV2, @@ -2718,6 +2719,161 @@ describe("TabProvider tab groups", () => { }) }) + // A preview tab is a slot the next open takes over, so a conversation opened + // only to read it is thrown away by whatever the user opens next. "Keep + // opened conversations" (Appearance settings) turns that slot off. It is off + // by default, so the preference is the only thing that changes any of this. + describe("keep opened conversations", () => { + const preferKeepingTabs = (on: boolean) => { + if (on) localStorage.setItem(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, "1") + else localStorage.removeItem(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS) + } + + afterEach(() => preferKeepingTabs(false)) + + /** `[id, isPinned]` for each tab the group holds, in strip order. */ + const stripState = (groupId: string) => + store() + .rawTabs.filter((tab) => groupOfId(tab.id) === groupId) + .map((tab) => [tab.id, tab.isPinned]) + + it("gives every opened conversation a tab of its own", async () => { + preferKeepingTabs(true) + await renderWithTabs([tabItem(1, 1, true)]) + const home = leaves()[0] + + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + act(() => { + store().openTab(1, 3, "codex", false, "Third") + }) + + // Pinned rather than merely spared: the strip sets a preview in oblique + // type, and a tab nothing will replace must not wear that mark. + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-2", true], + ["conv-1-codex-3", true], + ]) + expect(store().activeTabId).toBe("conv-1-codex-3") + }) + + it("still replaces the preview while the preference is off", async () => { + await renderWithTabs([tabItem(1, 1, true)]) + const home = leaves()[0] + + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + act(() => { + store().openTab(1, 3, "codex", false, "Third") + }) + + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-3", false], + ]) + expect(store().activeTabId).toBe("conv-1-codex-3") + }) + + it("focuses a conversation it already holds instead of opening it twice", async () => { + preferKeepingTabs(true) + await renderWithTabs([tabItem(1, 1, true)]) + const home = leaves()[0] + + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + act(() => { + store().switchTab("conv-1-codex-1") + }) + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-2", true], + ]) + expect(store().activeTabId).toBe("conv-1-codex-2") + }) + + it("leaves the double-click pin doing its job while the preference is off", async () => { + await renderWithTabs([tabItem(1, 1, true)]) + const home = leaves()[0] + + // A double click in the sidebar is the single-click open followed by the + // pinning one, so the preview it just parked is the tab it promotes. + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + act(() => { + store().openTab(1, 2, "codex", true, "Second") + }) + act(() => { + store().openTab(1, 3, "codex", false, "Third") + }) + + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-2", true], + ["conv-1-codex-3", false], + ]) + }) + + it("keeps every pane of a tiled group", async () => { + preferKeepingTabs(true) + await renderWithTabs([tabItem(1, 1, true)]) + const home = leaves()[0] + + act(() => { + store().toggleGroupTile(home) + }) + act(() => { + store().openTab(1, 2, "codex", false, "Second") + }) + act(() => { + store().openTab(1, 3, "codex", false, "Third") + }) + + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-2", true], + ["conv-1-codex-3", true], + ]) + }) + + it("keeps the tabs of every group, not just the focused one", async () => { + preferKeepingTabs(true) + await renderWithTabs([tabItem(1, 1, true), tabItem(1, 2)]) + const home = leaves()[0] + + act(() => { + store().splitTab("conv-1-codex-2", "right", { move: true }) + }) + const g1 = newLeafBeside(home) + act(() => { + store().openTab(1, 3, "codex", false, "Third") + }) + act(() => { + store().switchTab("conv-1-codex-1") + }) + act(() => { + store().openTab(1, 4, "codex", false, "Fourth") + }) + + expect(stripState(home)).toEqual([ + ["conv-1-codex-1", true], + ["conv-1-codex-4", true], + ]) + expect(stripState(g1)).toEqual([ + ["conv-1-codex-2", true], + ["conv-1-codex-3", true], + ]) + }) + }) + // The unmount classifier that decides whether a vanishing conversation view // keeps its ACP connection. Getting this wrong in either direction is // expensive: too broad leaks viewer subscriptions (the idle sweep skips diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index 6468b07e02..c681108b16 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -31,6 +31,13 @@ export function useWelcomeQuickActions() { return { showWelcomeQuickActions, setShowWelcomeQuickActions } } +/** 语义化包装:打开的会话是否各自保留一个标签页(关闭预览标签页的就地替换) */ +export function useKeepOpenedConversations() { + const { keepOpenedConversations, setKeepOpenedConversations } = + useAppearance() + return { keepOpenedConversations, setKeepOpenedConversations } +} + /** 界面字体(普通组件)。stack 已解析,可直接用于 style 或 CSS 变量。 */ export function useUiFont() { const { uiFont, setUiFont } = useAppearance() diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 66c1e78eca..a93baa00ef 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "تنطبق الحروف المتصلة في الطرفية على خطوط البرمجة المضمّنة فقط.", "preview": "معاينة" }, + "conversationTabs": { + "sectionTitle": "علامات تبويب المحادثات", + "sectionDescription": "النقر يفتح علامة تبويب تبقى حتى تغلقها، بدلاً من أن تحل محلها المحادثة التالية التي تفتحها.", + "keepOpened": "الإبقاء على المحادثات المفتوحة" + }, "welcomePanel": { "sectionTitle": "منطقة اختيار الوضع", "sectionDescription": "بطاقات الاختصار «تطوير الكود / الأعمال المكتبية» التي تظهر أعلى مربع الإدخال في صفحة المحادثة الجديدة.", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 791fd785d6..d9612a5e50 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "Terminal-Ligaturen gelten nur für mitgelieferte Programmierschriftarten.", "preview": "Vorschau" }, + "conversationTabs": { + "sectionTitle": "Konversations-Tabs", + "sectionDescription": "Ein Klick öffnet einen Tab, der bleibt, bis du ihn schließt, statt von der nächsten geöffneten Konversation ersetzt zu werden.", + "keepOpened": "Geöffnete Konversationen behalten" + }, "welcomePanel": { "sectionTitle": "Modusauswahlbereich", "sectionDescription": "Die Shortcut-Karten „Code-Entwicklung / Büroarbeit“ über dem Eingabefeld auf der Seite für eine neue Konversation.", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ecab7562ac..98980e6d74 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "Terminal ligatures apply only to bundled coding fonts.", "preview": "Preview" }, + "conversationTabs": { + "sectionTitle": "Conversation tabs", + "sectionDescription": "A click opens a tab that stays until you close it, instead of being replaced by the next conversation you open.", + "keepOpened": "Keep opened conversations" + }, "welcomePanel": { "sectionTitle": "Mode selection area", "sectionDescription": "The Code Development / Office Work shortcut cards shown above the composer on the new conversation page.", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index db03d9122f..0ac91a0960 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "Las ligaduras del terminal solo se aplican a las fuentes de programación incluidas.", "preview": "Vista previa" }, + "conversationTabs": { + "sectionTitle": "Pestañas de conversación", + "sectionDescription": "Un clic abre una pestaña que permanece hasta que la cierras, en lugar de ser reemplazada por la siguiente conversación que abras.", + "keepOpened": "Mantener las conversaciones abiertas" + }, "welcomePanel": { "sectionTitle": "Área de selección de modo", "sectionDescription": "Las tarjetas de acceso rápido «Desarrollo de código / Ofimática» que se muestran sobre el editor en la página de nueva conversación.", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 1a4d49f6e2..a726cada1a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "Les ligatures du terminal ne s’appliquent qu’aux polices de programmation intégrées.", "preview": "Aperçu" }, + "conversationTabs": { + "sectionTitle": "Onglets de conversation", + "sectionDescription": "Un clic ouvre un onglet qui reste jusqu’à ce que vous le fermiez, au lieu d’être remplacé par la conversation suivante que vous ouvrez.", + "keepOpened": "Conserver les conversations ouvertes" + }, "welcomePanel": { "sectionTitle": "Zone de sélection du mode", "sectionDescription": "Les cartes de raccourci « Développement / Bureautique » affichées au-dessus du champ de saisie sur la page de nouvelle conversation.", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fbe7afddbf..989521df4d 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "ターミナルの合字は内蔵のコーディングフォントにのみ適用されます。", "preview": "プレビュー" }, + "conversationTabs": { + "sectionTitle": "会話タブ", + "sectionDescription": "クリックで開いたタブは、次に開いた会話に置き換えられることなく、閉じるまで残ります。", + "keepOpened": "開いた会話を残す" + }, "welcomePanel": { "sectionTitle": "モード選択エリア", "sectionDescription": "新しい会話ページで入力欄の上に表示される「コード開発 / 日常業務」のショートカットカードです。", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f23e38116f..7d56b04ab0 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "터미널 합자는 내장 코딩 글꼴에만 적용됩니다.", "preview": "미리 보기" }, + "conversationTabs": { + "sectionTitle": "대화 탭", + "sectionDescription": "클릭으로 연 탭은 다음에 여는 대화로 바뀌지 않고 닫을 때까지 그대로 유지됩니다.", + "keepOpened": "연 대화 유지" + }, "welcomePanel": { "sectionTitle": "모드 선택 영역", "sectionDescription": "새 대화 페이지에서 입력창 위에 표시되는 '코드 개발 / 일상 업무' 바로가기 카드입니다.", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index d334c38757..43c0b84185 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "As ligaduras do terminal aplicam-se apenas às fontes de programação incluídas.", "preview": "Pré-visualização" }, + "conversationTabs": { + "sectionTitle": "Abas de conversa", + "sectionDescription": "Um clique abre uma aba que permanece até ser fechada, em vez de ser substituída pela próxima conversa aberta.", + "keepOpened": "Manter as conversas abertas" + }, "welcomePanel": { "sectionTitle": "Área de seleção de modo", "sectionDescription": "Os cartões de atalho «Desenvolvimento / Escritório» exibidos acima do campo de entrada na página de nova conversa.", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index e42916daf2..6a7d38c6b1 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "终端连字仅对内置编程字体生效。", "preview": "预览" }, + "conversationTabs": { + "sectionTitle": "会话标签页", + "sectionDescription": "单击打开的标签页会一直保留到手动关闭,而不会被下一个打开的会话就地替换。", + "keepOpened": "保留已打开的会话" + }, "welcomePanel": { "sectionTitle": "模式选择区域", "sectionDescription": "新会话页面输入框上方显示的「代码开发 / 日常办公」快捷卡片区域。", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d58afc27ba..e8bfed87a8 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -168,6 +168,11 @@ "terminalLigaturesHint": "終端機連字僅對內建程式字型生效。", "preview": "預覽" }, + "conversationTabs": { + "sectionTitle": "會話標籤頁", + "sectionDescription": "單擊開啟的標籤頁會一直保留到手動關閉,而不會被下一個開啟的會話就地取代。", + "keepOpened": "保留已開啟的會話" + }, "welcomePanel": { "sectionTitle": "模式選擇區域", "sectionDescription": "新會話頁面輸入框上方顯示的「程式開發 / 日常辦公」快捷卡片區域。", diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index e47c8c7c2a..7883661537 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -18,6 +18,15 @@ export const STORAGE_KEY_ZOOM_LEVEL = "codeg-zoom-level" // 缺省即回退为开启(保持历史行为);仅在欢迎态客户端渲染,无需预水合。 export const STORAGE_KEY_WELCOME_QUICK_ACTIONS = "codeg-welcome-quick-actions" +// Whether an opened conversation keeps its own tab instead of taking the +// group's single preview slot. Missing key falls back to preview tabs (the +// historical behavior). Read by the tab store at open time as well as by the +// Provider, so the settings window and the workspace window share one value +// through localStorage. Nothing renders from it on the first frame, so it +// needs no pre-hydration. +export const STORAGE_KEY_KEEP_OPENED_CONVERSATIONS = + "codeg-keep-opened-conversations" + // 字体偏好(界面 / 编辑器 / 终端)。 // 只有界面字体需要 *_STACK(已解析的 CSS font-family 栈),供 inline 脚本零依赖地 // 预水合写入 --font-sans;编辑器/终端字体只走各自的 Monaco/xterm 选项,水合后才挂载, diff --git a/src/stores/tab-store.ts b/src/stores/tab-store.ts index 0d76967df9..be1943c652 100644 --- a/src/stores/tab-store.ts +++ b/src/stores/tab-store.ts @@ -36,6 +36,7 @@ import { sweepOrphanDraftKeys, } from "@/lib/message-input-draft" import { discardAskSelectionPrompts } from "@/lib/ask-selection-handoff" +import { STORAGE_KEY_KEEP_OPENED_CONVERSATIONS } from "@/lib/appearance-script" import { batchCloseSlots, pushClosedTab, @@ -328,6 +329,26 @@ const TILE_MODE_STORAGE_KEY = "workspace:tile-mode" * flags), keyed by canonical tab ids. See `persistGroupState`. */ const TAB_GROUPS_STORAGE_KEY = "workspace:tab-groups:v1" +/** + * "Keep opened conversations" (Appearance settings): when it is on, opening a + * conversation is never a preview — it takes a tab of its own and keeps it + * until the user closes it. Off by default, which is the preview-tab behavior + * this app has always had. + * + * Read per open rather than cached: appearance settings live in their own + * window, so localStorage is what the two windows share, and reading it at the + * decision point means a change takes effect on the very next click without + * any cross-window plumbing. + */ +function keepOpenedConversations(): boolean { + if (typeof window === "undefined") return false + try { + return localStorage.getItem(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS) === "1" + } catch { + return false + } +} + /** Per-window/session identity stamped on every tab save and echoed back on * `tabs://changed`, so this client ignores its own broadcast (echo * suppression). Regenerated each load — it identifies the window for echo @@ -1155,6 +1176,13 @@ export const useTabStore = create()((set, get) => ({ ) || runtime.labels.untitledConversation const tabId = makeConversationTabId(folderId, agentType, conversationId) + // A conversation the user asked to keep is not a preview at all, so it + // opens exactly like a pinned open: its own slot, and no preview victim is + // looked for below. Being genuinely pinned is the point — the tab strip + // renders a preview in oblique type, and a tab that will never be replaced + // must not wear that mark (nor become replaceable again if the preference + // is later turned back off). + const pinned = pin || keepOpenedConversations() const newTab: TabItemInternal = { id: tabId, kind: "conversation", @@ -1162,10 +1190,10 @@ export const useTabStore = create()((set, get) => ({ conversationId, agentType, title: resolvedTitle, - isPinned: pin, + isPinned: pinned, } - if (pin) { + if (pinned) { set({ rawTabs: insertTab(prevState.rawTabs, newTab, opts?.index), activeTabId: tabId,