Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -370,6 +374,13 @@ export function AppearanceProvider({
const [showWelcomeQuickActions, setShowWelcomeQuickActionsState] =
useState<boolean>(() => readBool(STORAGE_KEY_WELCOME_QUICK_ACTIONS, true))

// 打开的会话是否保留各自的标签页:默认关闭,键缺失即回退为预览标签页的历史行为。
// 真正消费它的是 tab store(在 openTab 里直接读同一个键),这里只是设置面板的回显态。
const [keepOpenedConversations, setKeepOpenedConversationsState] =
useState<boolean>(() =>
readBool(STORAGE_KEY_KEEP_OPENED_CONVERSATIONS, false)
)

// 字体偏好的初始值从 localStorage 读 id/custom(视觉已由 inline 脚本就位,
// 这里只是回填选中态,不会造成闪烁)。
const [uiFont, setUiFontState] = useState<FontSelection>(() =>
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -1059,6 +1080,8 @@ export function AppearanceProvider({
setZoomLevel,
showWelcomeQuickActions,
setShowWelcomeQuickActions,
keepOpenedConversations,
setKeepOpenedConversations,
uiFont,
setUiFont,
editorFont,
Expand Down
36 changes: 35 additions & 1 deletion src/components/settings/appearance-settings.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -16,6 +23,7 @@ import {
useThemeColor,
useZoomLevel,
useWelcomeQuickActions,
useKeepOpenedConversations,
} from "@/hooks/use-appearance"
import { cn } from "@/lib/utils"
import {
Expand All @@ -40,6 +48,8 @@ export function AppearanceSettings() {
const { zoomLevel, setZoomLevel } = useZoomLevel()
const { showWelcomeQuickActions, setShowWelcomeQuickActions } =
useWelcomeQuickActions()
const { keepOpenedConversations, setKeepOpenedConversations } =
useKeepOpenedConversations()

const resolvedThemeLabel =
resolvedTheme === "dark"
Expand Down Expand Up @@ -214,6 +224,30 @@ export function AppearanceSettings() {
{/* ===== Workspace background ===== */}
<WorkspaceBackgroundSection />

{/* ===== Conversation tabs ===== */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">
{t("conversationTabs.sectionTitle")}
</h2>
</div>

<p className="text-xs text-muted-foreground leading-5">
{t("conversationTabs.sectionDescription")}
</p>

<label className="flex items-center gap-2">
<Switch
checked={keepOpenedConversations}
onCheckedChange={setKeepOpenedConversations}
/>
<span className="text-xs text-muted-foreground">
{t("conversationTabs.keepOpened")}
</span>
</label>
</section>

{/* ===== New conversation — mode selection area ===== */}
<section className="rounded-xl border bg-card p-4 space-y-4">
<div className="flex items-center gap-2">
Expand Down
158 changes: 157 additions & 1 deletion src/contexts/tab-context.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/use-appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@
"terminalLigaturesHint": "تنطبق الحروف المتصلة في الطرفية على خطوط البرمجة المضمّنة فقط.",
"preview": "معاينة"
},
"conversationTabs": {
"sectionTitle": "علامات تبويب المحادثات",
"sectionDescription": "النقر يفتح علامة تبويب تبقى حتى تغلقها، بدلاً من أن تحل محلها المحادثة التالية التي تفتحها.",
"keepOpened": "الإبقاء على المحادثات المفتوحة"
},
"welcomePanel": {
"sectionTitle": "منطقة اختيار الوضع",
"sectionDescription": "بطاقات الاختصار «تطوير الكود / الأعمال المكتبية» التي تظهر أعلى مربع الإدخال في صفحة المحادثة الجديدة.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@
"terminalLigaturesHint": "ターミナルの合字は内蔵のコーディングフォントにのみ適用されます。",
"preview": "プレビュー"
},
"conversationTabs": {
"sectionTitle": "会話タブ",
"sectionDescription": "クリックで開いたタブは、次に開いた会話に置き換えられることなく、閉じるまで残ります。",
"keepOpened": "開いた会話を残す"
},
"welcomePanel": {
"sectionTitle": "モード選択エリア",
"sectionDescription": "新しい会話ページで入力欄の上に表示される「コード開発 / 日常業務」のショートカットカードです。",
Expand Down
Loading
Loading