From 955f87561b33af4a4958a0525dcf2de9615bb653 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:38:30 +0700 Subject: [PATCH] feat(frontend): enable pinning and unpinning of conversation tabs (#11659) --- .../conversation-tabs-context-menu.tsx | 116 ++++++++++++++++++ .../conversation-tabs/conversation-tabs.tsx | 41 ++++++- frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 +++ frontend/src/icons/pill-fill.svg | 3 + frontend/src/icons/pill.svg | 3 + 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx create mode 100644 frontend/src/icons/pill-fill.svg create mode 100644 frontend/src/icons/pill.svg diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx new file mode 100644 index 0000000000..36c1b69cc5 --- /dev/null +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from "react-i18next"; +import { useLocalStorage } from "@uidotdev/usehooks"; +import { ContextMenu } from "#/ui/context-menu"; +import { ContextMenuListItem } from "../../context-menu/context-menu-list-item"; +import { useClickOutsideElement } from "#/hooks/use-click-outside-element"; +import { I18nKey } from "#/i18n/declaration"; +import TerminalIcon from "#/icons/terminal.svg?react"; +import GlobeIcon from "#/icons/globe.svg?react"; +import ServerIcon from "#/icons/server.svg?react"; +import GitChanges from "#/icons/git_changes.svg?react"; +import VSCodeIcon from "#/icons/vscode.svg?react"; +import PillIcon from "#/icons/pill.svg?react"; +import PillFillIcon from "#/icons/pill-fill.svg?react"; +import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; +import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; + +interface ConversationTabsContextMenuProps { + isOpen: boolean; + onClose: () => void; +} + +export function ConversationTabsContextMenu({ + isOpen, + onClose, +}: ConversationTabsContextMenuProps) { + const ref = useClickOutsideElement(onClose); + const { t } = useTranslation(); + const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage( + "conversation-unpinned-tabs", + [], + ); + + const shouldUsePlanningAgent = USE_PLANNING_AGENT(); + + const tabConfig = [ + { + tab: "editor", + icon: GitChanges, + i18nKey: I18nKey.COMMON$CHANGES, + }, + { + tab: "vscode", + icon: VSCodeIcon, + i18nKey: I18nKey.COMMON$CODE, + }, + { + tab: "terminal", + icon: TerminalIcon, + i18nKey: I18nKey.COMMON$TERMINAL, + }, + { + tab: "served", + icon: ServerIcon, + i18nKey: I18nKey.COMMON$APP, + }, + { + tab: "browser", + icon: GlobeIcon, + i18nKey: I18nKey.COMMON$BROWSER, + }, + ]; + + if (shouldUsePlanningAgent) { + tabConfig.unshift({ + tab: "planner", + icon: LessonPlanIcon, + i18nKey: I18nKey.COMMON$PLANNER, + }); + } + + if (!isOpen) return null; + + const handleTabClick = (tab: string) => { + const tabString = tab; + if (unpinnedTabs.includes(tabString)) { + // Tab is unpinned, pin it (remove from unpinned list) + setUnpinnedTabs( + unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString), + ); + } else { + // Tab is pinned, unpin it (add to unpinned list) + setUnpinnedTabs([...unpinnedTabs, tabString]); + } + }; + + const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string); + + return ( + + {tabConfig.map(({ tab, icon: Icon, i18nKey }) => { + const pinned = isTabPinned(tab); + return ( + handleTabClick(tab)} + className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]" + > + + {t(i18nKey)} + {pinned ? ( + + ) : ( + + )} + + ); + })} + + ); +} diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx index 4e920349b9..e84466bd22 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocalStorage } from "@uidotdev/usehooks"; import TerminalIcon from "#/icons/terminal.svg?react"; @@ -6,6 +6,7 @@ import GlobeIcon from "#/icons/globe.svg?react"; import ServerIcon from "#/icons/server.svg?react"; import GitChanges from "#/icons/git_changes.svg?react"; import VSCodeIcon from "#/icons/vscode.svg?react"; +import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react"; import LessonPlanIcon from "#/icons/lesson-plan.svg?react"; import { cn } from "#/utils/utils"; import { ConversationTabNav } from "./conversation-tab-nav"; @@ -16,6 +17,7 @@ import { useConversationStore, type ConversationTab, } from "#/state/conversation-store"; +import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu"; import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; export function ConversationTabs() { @@ -26,6 +28,8 @@ export function ConversationTabs() { setSelectedTab, } = useConversationStore(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + // Persist selectedTab and isRightPanelShown in localStorage const [persistedSelectedTab, setPersistedSelectedTab] = useLocalStorage( @@ -36,6 +40,11 @@ export function ConversationTabs() { const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] = useLocalStorage("conversation-right-panel-shown", true); + const [persistedUnpinnedTabs] = useLocalStorage( + "conversation-unpinned-tabs", + [], + ); + const shouldUsePlanningAgent = USE_PLANNING_AGENT(); const onTabChange = (value: ConversationTab | null) => { @@ -91,6 +100,7 @@ export function ConversationTabs() { const tabs = [ { + tabValue: "editor", isActive: isTabActive("editor"), icon: GitChanges, onClick: () => onTabSelected("editor"), @@ -99,6 +109,7 @@ export function ConversationTabs() { label: t(I18nKey.COMMON$CHANGES), }, { + tabValue: "vscode", isActive: isTabActive("vscode"), icon: VSCodeIcon, onClick: () => onTabSelected("vscode"), @@ -107,6 +118,7 @@ export function ConversationTabs() { label: t(I18nKey.COMMON$CODE), }, { + tabValue: "terminal", isActive: isTabActive("terminal"), icon: TerminalIcon, onClick: () => onTabSelected("terminal"), @@ -116,6 +128,7 @@ export function ConversationTabs() { className: "pl-2", }, { + tabValue: "served", isActive: isTabActive("served"), icon: ServerIcon, onClick: () => onTabSelected("served"), @@ -124,6 +137,7 @@ export function ConversationTabs() { label: t(I18nKey.COMMON$APP), }, { + tabValue: "browser", isActive: isTabActive("browser"), icon: GlobeIcon, onClick: () => onTabSelected("browser"), @@ -135,6 +149,7 @@ export function ConversationTabs() { if (shouldUsePlanningAgent) { tabs.unshift({ + tabValue: "planner", isActive: isTabActive("planner"), icon: LessonPlanIcon, onClick: () => onTabSelected("planner"), @@ -144,6 +159,11 @@ export function ConversationTabs() { }); } + // Filter out unpinned tabs + const visibleTabs = tabs.filter( + (tab) => !persistedUnpinnedTabs.includes(tab.tabValue), + ); + return (
- {tabs.map( + {visibleTabs.map( ( { icon, @@ -179,6 +199,23 @@ export function ConversationTabs() { ), )} +
+ + setIsMenuOpen(false)} + /> +
); } diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 5ada07648e..436ea5d2f3 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -932,5 +932,6 @@ export enum I18nKey { TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION", TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED", AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION", + COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS", COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index ca6682ae8c..5423ab2d85 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -14911,6 +14911,22 @@ "de": "Warte auf Benutzerbestätigung", "uk": "Очікується підтвердження користувача" }, + "COMMON$MORE_OPTIONS": { + "en": "More options", + "ja": "その他のオプション", + "zh-CN": "更多选项", + "zh-TW": "更多選項", + "ko-KR": "추가 옵션", + "no": "Flere alternativer", + "it": "Altre opzioni", + "pt": "Mais opções", + "es": "Más opciones", + "ar": "خيارات إضافية", + "fr": "Plus d'options", + "tr": "Daha fazla seçenek", + "de": "Weitere Optionen", + "uk": "Більше опцій" + }, "COMMON$CREATE_A_PLAN": { "en": "Create a plan", "ja": "プランを作成する", diff --git a/frontend/src/icons/pill-fill.svg b/frontend/src/icons/pill-fill.svg new file mode 100644 index 0000000000..ef3ec962ee --- /dev/null +++ b/frontend/src/icons/pill-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/icons/pill.svg b/frontend/src/icons/pill.svg new file mode 100644 index 0000000000..50dfba747b --- /dev/null +++ b/frontend/src/icons/pill.svg @@ -0,0 +1,3 @@ + + +