Skip to content

Commit 0d13756

Browse files
authored
feat: Experimental Feature Toggle (#525)
* feat: Experimental Feature Toggle * chore: add open app directory action * chore: disable experimental feature test case
1 parent 6f3b17b commit 0d13756

File tree

10 files changed

+224
-116
lines changed

10 files changed

+224
-116
lines changed

electron/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,15 @@ function handleIPCs() {
212212
return app.getVersion();
213213
});
214214

215+
/**
216+
* Handles the "openAppDirectory" IPC message by opening the app's user data directory.
217+
* The `shell.openPath` method is used to open the directory in the user's default file explorer.
218+
* @param _event - The IPC event object.
219+
*/
220+
ipcMain.handle("openAppDirectory", async (_event) => {
221+
shell.openPath(app.getPath("userData"));
222+
});
223+
215224
/**
216225
* Opens a URL in the user's default browser.
217226
* @param _event - The IPC event object.

electron/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
2828

2929
relaunch: () => ipcRenderer.invoke("relaunch"),
3030

31+
openAppDirectory: () => ipcRenderer.invoke("openAppDirectory"),
32+
3133
deleteFile: (filePath: string) => ipcRenderer.invoke("deleteFile", filePath),
3234

3335
installRemotePlugin: (pluginName: string) =>

electron/tests/navigation.e2e.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,12 @@ test("renders left navigation panel", async () => {
4040
expect(chatSection).toBe(false);
4141

4242
// Home actions
43-
const botBtn = await page.getByTestId("Bot").first().isEnabled();
43+
/* Disable unstable feature tests
44+
** const botBtn = await page.getByTestId("Bot").first().isEnabled();
45+
** Enable back when it is whitelisted
46+
*/
47+
4448
const myModelsBtn = await page.getByTestId("My Models").first().isEnabled();
4549
const settingsBtn = await page.getByTestId("Settings").first().isEnabled();
46-
expect([botBtn, myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
50+
expect([myModelsBtn, settingsBtn].filter((e) => !e).length).toBe(0);
4751
});

web/app/_components/LeftHeaderAction/index.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import React from 'react'
3+
import React, { useContext } from 'react'
44
import SecondaryButton from '../SecondaryButton'
55
import { useSetAtom, useAtomValue } from 'jotai'
66
import {
@@ -13,13 +13,17 @@ import { useGetDownloadedModels } from '@hooks/useGetDownloadedModels'
1313
import { Button } from '@uikit'
1414
import { activeAssistantModelAtom } from '@helpers/atoms/Model.atom'
1515
import { showingModalNoActiveModel } from '@helpers/atoms/Modal.atom'
16+
import {
17+
FeatureToggleContext,
18+
} from '@helpers/FeatureToggleWrapper'
1619

1720
const LeftHeaderAction: React.FC = () => {
1821
const setMainView = useSetAtom(setMainViewStateAtom)
1922
const { downloadedModels } = useGetDownloadedModels()
2023
const activeModel = useAtomValue(activeAssistantModelAtom)
2124
const { requestCreateConvo } = useCreateConversation()
2225
const setShowModalNoActiveModel = useSetAtom(showingModalNoActiveModel)
26+
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
2327

2428
const onExploreClick = () => {
2529
setMainView(MainViewState.ExploreModel)
@@ -50,12 +54,14 @@ const LeftHeaderAction: React.FC = () => {
5054
className="w-full flex-1"
5155
icon={<MagnifyingGlassIcon width={16} height={16} />}
5256
/>
53-
<SecondaryButton
54-
title={'Create bot'}
55-
onClick={onCreateBotClicked}
56-
className="w-full flex-1"
57-
icon={<PlusIcon width={16} height={16} />}
58-
/>
57+
{experimentalFeatureEnabed && (
58+
<SecondaryButton
59+
title={'Create bot'}
60+
onClick={onCreateBotClicked}
61+
className="w-full flex-1"
62+
icon={<PlusIcon width={16} height={16} />}
63+
/>
64+
)}
5965
</div>
6066
<Button
6167
onClick={onNewConversationClick}

web/containers/Providers/index.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
isCorePluginInstalled,
1616
setupBasePlugins,
1717
} from '@services/pluginService'
18+
import { FeatureToggleWrapper } from '@helpers/FeatureToggleWrapper'
1819

1920
const Providers = (props: PropsWithChildren) => {
2021
const [setupCore, setSetupCore] = useState(false)
@@ -70,9 +71,11 @@ const Providers = (props: PropsWithChildren) => {
7071
{setupCore && (
7172
<ThemeWrapper>
7273
{activated ? (
73-
<EventListenerWrapper>
74-
<ModalWrapper>{children}</ModalWrapper>
75-
</EventListenerWrapper>
74+
<FeatureToggleWrapper>
75+
<EventListenerWrapper>
76+
<ModalWrapper>{children}</ModalWrapper>
77+
</EventListenerWrapper>
78+
</FeatureToggleWrapper>
7679
) : (
7780
<div className="flex h-screen w-screen items-center justify-center bg-background">
7881
<CompactLogo width={56} height={56} />

web/containers/Sidebar/Left.tsx

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, { useContext } from 'react'
22
import { useAtomValue, useSetAtom } from 'jotai'
33
import {
44
MainViewState,
@@ -20,6 +20,9 @@ import { twMerge } from 'tailwind-merge'
2020
import { showingBotListModalAtom } from '@helpers/atoms/Modal.atom'
2121
import useGetBots from '@hooks/useGetBots'
2222
import { useUserConfigs } from '@hooks/useUserConfigs'
23+
import {
24+
FeatureToggleContext,
25+
} from '@helpers/FeatureToggleWrapper'
2326

2427
export const SidebarLeft = () => {
2528
const [config] = useUserConfigs()
@@ -28,6 +31,7 @@ export const SidebarLeft = () => {
2831
const setBotListModal = useSetAtom(showingBotListModalAtom)
2932
const { downloadedModels } = useGetDownloadedModels()
3033
const { getAllBots } = useGetBots()
34+
const { experimentalFeatureEnabed } = useContext(FeatureToggleContext)
3135

3236
const onMenuClick = (mainViewState: MainViewState) => {
3337
if (currentState === mainViewState) return
@@ -88,18 +92,21 @@ export const SidebarLeft = () => {
8892
icon: <LayoutGrid size={20} className="flex-shrink-0" />,
8993
state: MainViewState.MyModel,
9094
},
91-
{
92-
name: 'Bot',
93-
icon: <Bot size={20} className="flex-shrink-0" />,
94-
state: MainViewState.CreateBot,
95-
},
95+
...(experimentalFeatureEnabed
96+
? [
97+
{
98+
name: 'Bot',
99+
icon: <Bot size={20} className="flex-shrink-0" />,
100+
state: MainViewState.CreateBot,
101+
},
102+
]
103+
: []),
96104
{
97105
name: 'Settings',
98106
icon: <Settings size={20} className="flex-shrink-0" />,
99107
state: MainViewState.Setting,
100108
},
101109
]
102-
103110
return (
104111
<m.div
105112
initial={false}
@@ -124,42 +131,44 @@ export const SidebarLeft = () => {
124131
config.sidebarLeftExpand ? 'items-start' : 'items-center'
125132
)}
126133
>
127-
{menus.map((menu, i) => {
128-
const isActive = currentState === menu.state
129-
const isBotMenu = menu.name === 'Bot'
130-
return (
131-
<div className="relative w-full px-4 py-2" key={i}>
132-
<button
133-
data-testid={menu.name}
134-
className={twMerge(
135-
'flex w-full flex-shrink-0 items-center gap-x-2',
136-
config.sidebarLeftExpand
137-
? 'justify-start'
138-
: 'justify-center'
139-
)}
140-
onClick={() =>
141-
isBotMenu ? onBotListClick() : onMenuClick(menu.state)
142-
}
143-
>
144-
{menu.icon}
145-
<m.span
146-
initial={false}
147-
variants={variant}
148-
animate={config.sidebarLeftExpand ? 'show' : 'hide'}
149-
className="text-xs font-semibold text-muted-foreground"
134+
{menus
135+
.filter((menu) => !!menu)
136+
.map((menu, i) => {
137+
const isActive = currentState === menu.state
138+
const isBotMenu = menu.name === 'Bot'
139+
return (
140+
<div className="relative w-full px-4 py-2" key={i}>
141+
<button
142+
data-testid={menu.name}
143+
className={twMerge(
144+
'flex w-full flex-shrink-0 items-center gap-x-2',
145+
config.sidebarLeftExpand
146+
? 'justify-start'
147+
: 'justify-center'
148+
)}
149+
onClick={() =>
150+
isBotMenu ? onBotListClick() : onMenuClick(menu.state)
151+
}
150152
>
151-
{menu.name}
152-
</m.span>
153-
</button>
154-
{isActive ? (
155-
<m.div
156-
className="absolute inset-0 left-2 -z-10 h-full w-[calc(100%-16px)] rounded-md bg-accent/20 p-2 backdrop-blur-lg"
157-
layoutId="active-state"
158-
/>
159-
) : null}
160-
</div>
161-
)
162-
})}
153+
{menu.icon}
154+
<m.span
155+
initial={false}
156+
variants={variant}
157+
animate={config.sidebarLeftExpand ? 'show' : 'hide'}
158+
className="text-xs font-semibold text-muted-foreground"
159+
>
160+
{menu.name}
161+
</m.span>
162+
</button>
163+
{isActive ? (
164+
<m.div
165+
className="absolute inset-0 left-2 -z-10 h-full w-[calc(100%-16px)] rounded-md bg-accent/20 p-2 backdrop-blur-lg"
166+
layoutId="active-state"
167+
/>
168+
) : null}
169+
</div>
170+
)
171+
})}
163172
</div>
164173
<m.div
165174
initial={false}
@@ -170,19 +179,15 @@ export const SidebarLeft = () => {
170179
<div className="space-y-2 rounded-lg border border-border bg-background/50 p-3">
171180
<button
172181
onClick={() =>
173-
window.coreAPI?.openExternalUrl(
174-
'https://discord.gg/AsJ8krTT3N'
175-
)
182+
window.coreAPI?.openExternalUrl('https://discord.gg/AsJ8krTT3N')
176183
}
177184
className="block text-xs font-semibold text-muted-foreground"
178185
>
179186
Discord
180187
</button>
181188
<button
182189
onClick={() =>
183-
window.coreAPI?.openExternalUrl(
184-
'https://twitter.com/janhq_'
185-
)
190+
window.coreAPI?.openExternalUrl('https://twitter.com/janhq_')
186191
}
187192
className="block text-xs font-semibold text-muted-foreground"
188193
>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React, { ReactNode, useEffect, useState } from 'react'
2+
3+
interface FeatureToggleContextType {
4+
experimentalFeatureEnabed: boolean
5+
setExperimentalFeatureEnabled: (on: boolean) => void
6+
}
7+
8+
const initialContext: FeatureToggleContextType = {
9+
experimentalFeatureEnabed: false,
10+
setExperimentalFeatureEnabled: (boolean) => {},
11+
}
12+
13+
export const FeatureToggleContext =
14+
React.createContext<FeatureToggleContextType>(initialContext)
15+
16+
export function FeatureToggleWrapper({ children }: { children: ReactNode }) {
17+
const EXPERIMENTAL_FEATURE_ENABLED = 'expermientalFeatureEnabled'
18+
const [experimentalEnabed, setExperimentalEnabled] = useState<boolean>(false)
19+
20+
useEffect(() => {
21+
// Global experimental feature is disabled
22+
let globalFeatureEnabled = false
23+
if (localStorage.getItem(EXPERIMENTAL_FEATURE_ENABLED) !== 'true') {
24+
globalFeatureEnabled = true
25+
}
26+
}, [])
27+
28+
const setExperimentalFeature = (on: boolean) => {
29+
localStorage.setItem(EXPERIMENTAL_FEATURE_ENABLED, on ? 'true' : 'false')
30+
setExperimentalEnabled(on)
31+
}
32+
33+
return (
34+
<FeatureToggleContext.Provider
35+
value={{
36+
experimentalFeatureEnabed: experimentalEnabed,
37+
setExperimentalFeatureEnabled: setExperimentalFeature,
38+
}}
39+
>
40+
{children}
41+
</FeatureToggleContext.Provider>
42+
)
43+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use client'
2+
3+
import React, { useContext, useRef } from 'react'
4+
import { Button, Switch } from '@uikit'
5+
import { FeatureToggleContext } from '@helpers/FeatureToggleWrapper'
6+
7+
const Advanced = () => {
8+
const { experimentalFeatureEnabed, setExperimentalFeatureEnabled } =
9+
useContext(FeatureToggleContext)
10+
const fileInputRef = useRef<HTMLInputElement | null>(null)
11+
return (
12+
<div className="block w-full">
13+
<div className="flex w-full items-start justify-between border-b border-gray-200 py-4 first:pt-0 last:border-none dark:border-gray-800">
14+
<div className="w-4/5 flex-shrink-0 space-y-1.5">
15+
<div className="flex gap-x-2">
16+
<h6 className="text-sm font-semibold capitalize">
17+
Experimental Mode
18+
</h6>
19+
</div>
20+
<p className="whitespace-pre-wrap leading-relaxed text-gray-600 dark:text-gray-400">
21+
Enable experimental features that may be unstable or not fully
22+
tested.
23+
</p>
24+
</div>
25+
<Switch
26+
checked={experimentalFeatureEnabed}
27+
onCheckedChange={(e) => {
28+
if (e === true) {
29+
setExperimentalFeatureEnabled(true)
30+
} else {
31+
setExperimentalFeatureEnabled(false)
32+
}
33+
}}
34+
/>
35+
</div>
36+
{window.electronAPI && (
37+
<div className="flex w-full items-start justify-between border-b border-gray-200 py-4 first:pt-0 last:border-none dark:border-gray-800">
38+
<div className="w-4/5 flex-shrink-0 space-y-1.5">
39+
<div className="flex gap-x-2">
40+
<h6 className="text-sm font-semibold capitalize">
41+
Open App Directory
42+
</h6>
43+
</div>
44+
<p className="whitespace-pre-wrap leading-relaxed text-gray-600 dark:text-gray-400">
45+
Open the directory where the app data is located.
46+
</p>
47+
</div>
48+
<div>
49+
<Button
50+
size="sm"
51+
themes="outline"
52+
onClick={() => window.electronAPI.openAppDirectory()}
53+
>
54+
Open
55+
</Button>
56+
</div>
57+
</div>
58+
)}
59+
</div>
60+
)
61+
}
62+
63+
export default Advanced

0 commit comments

Comments
 (0)