From 9200ec4091d8d00bef06542ce9f78a8001fa080e Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:28:03 -0700 Subject: [PATCH] feat(tabs): a setting that keeps an opened conversation in its own tab Conversation tabs are previews by default: opening one with `pin=false` overwrites the focused group's unpinned tab in place, and a conversation only becomes pinned once a message is sent in it. A conversation opened just to read, typically one that has already finished, therefore never pins, and the next thing opened silently takes its slot. Appearance settings gains "Keep opened conversations", stored under `codeg-keep-opened-conversations` beside the other appearance preferences and off by default, so nothing changes for anyone who does not go looking for it. With it on, `openTab` treats every conversation open like a pinned one: the tab takes a slot of its own and no preview victim is looked for. The tab is marked pinned rather than merely spared. The strip sets a preview in oblique type, so a tab that will never be replaced must not wear that mark, and it must not become replaceable again if the preference is later turned back off. The store reads the key at open time instead of caching it. Appearance settings live in their own window, localStorage is the surface the two windows share, and reading it at the decision point makes a change take effect on the very next click. Double-click still pins, an already-open conversation still focuses instead of opening twice, and sending a message still pins. --- src/components/appearance-provider.tsx | 23 +++ .../settings/appearance-settings.tsx | 36 +++- src/contexts/tab-context.test.tsx | 158 +++++++++++++++++- src/hooks/use-appearance.ts | 7 + src/i18n/messages/ar.json | 5 + src/i18n/messages/de.json | 5 + src/i18n/messages/en.json | 5 + src/i18n/messages/es.json | 5 + src/i18n/messages/fr.json | 5 + src/i18n/messages/ja.json | 5 + src/i18n/messages/ko.json | 5 + src/i18n/messages/pt.json | 5 + src/i18n/messages/zh-CN.json | 5 + src/i18n/messages/zh-TW.json | 5 + src/lib/appearance-script.ts | 9 + src/stores/tab-store.ts | 32 +++- 16 files changed, 311 insertions(+), 4 deletions(-) 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,