feat(frontend): enable pinning and unpinning of conversation tabs (#11659)

This commit is contained in:
Hiep Le 2025-11-07 13:38:30 +07:00 committed by GitHub
parent 1e5bff82f2
commit 955f87561b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 178 additions and 2 deletions

View File

@ -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<HTMLUListElement>(onClose);
const { t } = useTranslation();
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
"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 (
<ContextMenu
testId="conversation-tabs-context-menu"
ref={ref}
alignment="right"
position="bottom"
className="mt-2 w-fit z-[9999]"
>
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
const pinned = isTabPinned(tab);
return (
<ContextMenuListItem
key={tab}
onClick={() => handleTabClick(tab)}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
<Icon className="w-4 h-4" />
<span className="text-white text-sm">{t(i18nKey)}</span>
{pinned ? (
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
) : (
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
)}
</ContextMenuListItem>
);
})}
</ContextMenu>
);
}

View File

@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks"; import { useLocalStorage } from "@uidotdev/usehooks";
import TerminalIcon from "#/icons/terminal.svg?react"; 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 ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react"; import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.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 LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { cn } from "#/utils/utils"; import { cn } from "#/utils/utils";
import { ConversationTabNav } from "./conversation-tab-nav"; import { ConversationTabNav } from "./conversation-tab-nav";
@ -16,6 +17,7 @@ import {
useConversationStore, useConversationStore,
type ConversationTab, type ConversationTab,
} from "#/state/conversation-store"; } from "#/state/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags"; import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
export function ConversationTabs() { export function ConversationTabs() {
@ -26,6 +28,8 @@ export function ConversationTabs() {
setSelectedTab, setSelectedTab,
} = useConversationStore(); } = useConversationStore();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Persist selectedTab and isRightPanelShown in localStorage // Persist selectedTab and isRightPanelShown in localStorage
const [persistedSelectedTab, setPersistedSelectedTab] = const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>( useLocalStorage<ConversationTab | null>(
@ -36,6 +40,11 @@ export function ConversationTabs() {
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] = const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true); useLocalStorage<boolean>("conversation-right-panel-shown", true);
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
[],
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT(); const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const onTabChange = (value: ConversationTab | null) => { const onTabChange = (value: ConversationTab | null) => {
@ -91,6 +100,7 @@ export function ConversationTabs() {
const tabs = [ const tabs = [
{ {
tabValue: "editor",
isActive: isTabActive("editor"), isActive: isTabActive("editor"),
icon: GitChanges, icon: GitChanges,
onClick: () => onTabSelected("editor"), onClick: () => onTabSelected("editor"),
@ -99,6 +109,7 @@ export function ConversationTabs() {
label: t(I18nKey.COMMON$CHANGES), label: t(I18nKey.COMMON$CHANGES),
}, },
{ {
tabValue: "vscode",
isActive: isTabActive("vscode"), isActive: isTabActive("vscode"),
icon: VSCodeIcon, icon: VSCodeIcon,
onClick: () => onTabSelected("vscode"), onClick: () => onTabSelected("vscode"),
@ -107,6 +118,7 @@ export function ConversationTabs() {
label: t(I18nKey.COMMON$CODE), label: t(I18nKey.COMMON$CODE),
}, },
{ {
tabValue: "terminal",
isActive: isTabActive("terminal"), isActive: isTabActive("terminal"),
icon: TerminalIcon, icon: TerminalIcon,
onClick: () => onTabSelected("terminal"), onClick: () => onTabSelected("terminal"),
@ -116,6 +128,7 @@ export function ConversationTabs() {
className: "pl-2", className: "pl-2",
}, },
{ {
tabValue: "served",
isActive: isTabActive("served"), isActive: isTabActive("served"),
icon: ServerIcon, icon: ServerIcon,
onClick: () => onTabSelected("served"), onClick: () => onTabSelected("served"),
@ -124,6 +137,7 @@ export function ConversationTabs() {
label: t(I18nKey.COMMON$APP), label: t(I18nKey.COMMON$APP),
}, },
{ {
tabValue: "browser",
isActive: isTabActive("browser"), isActive: isTabActive("browser"),
icon: GlobeIcon, icon: GlobeIcon,
onClick: () => onTabSelected("browser"), onClick: () => onTabSelected("browser"),
@ -135,6 +149,7 @@ export function ConversationTabs() {
if (shouldUsePlanningAgent) { if (shouldUsePlanningAgent) {
tabs.unshift({ tabs.unshift({
tabValue: "planner",
isActive: isTabActive("planner"), isActive: isTabActive("planner"),
icon: LessonPlanIcon, icon: LessonPlanIcon,
onClick: () => onTabSelected("planner"), onClick: () => onTabSelected("planner"),
@ -144,6 +159,11 @@ export function ConversationTabs() {
}); });
} }
// Filter out unpinned tabs
const visibleTabs = tabs.filter(
(tab) => !persistedUnpinnedTabs.includes(tab.tabValue),
);
return ( return (
<div <div
className={cn( className={cn(
@ -151,7 +171,7 @@ export function ConversationTabs() {
"flex flex-row justify-start lg:justify-end items-center gap-4.5", "flex flex-row justify-start lg:justify-end items-center gap-4.5",
)} )}
> >
{tabs.map( {visibleTabs.map(
( (
{ {
icon, icon,
@ -179,6 +199,23 @@ export function ConversationTabs() {
</ChatActionTooltip> </ChatActionTooltip>
), ),
)} )}
<div className="relative">
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={cn(
"p-1 pl-0 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
)}
aria-label={t(I18nKey.COMMON$MORE_OPTIONS)}
>
<ThreeDotsVerticalIcon className={cn("w-5 h-5 text-inherit")} />
</button>
<ConversationTabsContextMenu
isOpen={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
/>
</div>
</div> </div>
); );
} }

View File

@ -932,5 +932,6 @@ export enum I18nKey {
TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION", TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED", TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION", 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", COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
} }

View File

@ -14911,6 +14911,22 @@
"de": "Warte auf Benutzerbestätigung", "de": "Warte auf Benutzerbestätigung",
"uk": "Очікується підтвердження користувача" "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": { "COMMON$CREATE_A_PLAN": {
"en": "Create a plan", "en": "Create a plan",
"ja": "プランを作成する", "ja": "プランを作成する",

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M10.9706 10.1586C10.2819 10.0621 9.56905 10.1784 8.93842 10.5075C8.88672 10.5345 8.83557 10.5629 8.78504 10.5928C8.54529 10.7344 8.3193 10.9082 8.11331 11.1142L9.83702 12.8372L7.00054 15.75L7 17H8.25064L11.1628 14.1632L12.8857 15.8865C13.0917 15.6805 13.2655 15.4545 13.4071 15.2147C13.437 15.1642 13.4654 15.1131 13.4924 15.0614C13.8215 14.4308 13.9378 13.718 13.8413 13.0293L17 10.6946L13.3053 7L10.9706 10.1586Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.13832 8.76476C8.25957 8.17949 9.52697 7.9727 10.7515 8.14436L14.9025 2.52826L21.4717 9.09737L15.8555 13.2484C16.0272 14.4729 15.8204 15.7403 15.2351 16.8616C15.1872 16.9535 15.1366 17.0444 15.0836 17.1343C14.8318 17.5605 14.5228 17.9624 14.1566 18.3286L10.4443 14.6163L4.75207 20.3085H3.69141V19.2478L9.38361 13.5556L5.6713 9.84332C6.03755 9.47708 6.43936 9.16808 6.86562 8.91632C6.95547 8.86326 7.04641 8.81274 7.13832 8.76476ZM15.0735 4.82054L19.1794 8.92641L14.2461 12.5727L14.3701 13.4567C14.492 14.3266 14.3596 15.2233 13.9758 16.0265L7.97338 10.0241C8.77665 9.64035 9.67335 9.50789 10.5432 9.62984L11.4272 9.75376L15.0735 4.82054Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B