feat: starter screen (#3494)
* feat: starter screen * chore: fix unused import * chore: update see all color
This commit is contained in:
parent
7d12de1c4a
commit
7d6fd658f4
@ -12,9 +12,12 @@ import { twMerge } from 'tailwind-merge'
|
|||||||
|
|
||||||
import { MainViewState } from '@/constants/screens'
|
import { MainViewState } from '@/constants/screens'
|
||||||
|
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
|
import { mainViewStateAtom, showLeftPanelAtom } from '@/helpers/atoms/App.atom'
|
||||||
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
import { editMessageAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
import { serverEnabledAtom } from '@/helpers/atoms/LocalServer.atom'
|
||||||
|
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||||
import {
|
import {
|
||||||
reduceTransparentAtom,
|
reduceTransparentAtom,
|
||||||
selectedSettingAtom,
|
selectedSettingAtom,
|
||||||
@ -28,6 +31,7 @@ export default function RibbonPanel() {
|
|||||||
const matches = useMediaQuery('(max-width: 880px)')
|
const matches = useMediaQuery('(max-width: 880px)')
|
||||||
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
const reduceTransparent = useAtomValue(reduceTransparentAtom)
|
||||||
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||||
|
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||||
|
|
||||||
const onMenuClick = (state: MainViewState) => {
|
const onMenuClick = (state: MainViewState) => {
|
||||||
if (mainViewState === state) return
|
if (mainViewState === state) return
|
||||||
@ -37,6 +41,10 @@ export default function RibbonPanel() {
|
|||||||
setEditMessage('')
|
setEditMessage('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDownloadALocalModel = downloadedModels.some((x) =>
|
||||||
|
localEngines.includes(x.engine)
|
||||||
|
)
|
||||||
|
|
||||||
const RibbonNavMenus = [
|
const RibbonNavMenus = [
|
||||||
{
|
{
|
||||||
name: 'Thread',
|
name: 'Thread',
|
||||||
@ -77,7 +85,10 @@ export default function RibbonPanel() {
|
|||||||
'border-none',
|
'border-none',
|
||||||
!showLeftPanel && !reduceTransparent && 'border-none',
|
!showLeftPanel && !reduceTransparent && 'border-none',
|
||||||
matches && !reduceTransparent && 'border-none',
|
matches && !reduceTransparent && 'border-none',
|
||||||
reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]'
|
reduceTransparent && ' bg-[hsla(var(--ribbon-panel-bg))]',
|
||||||
|
mainViewState === MainViewState.Thread &&
|
||||||
|
!isDownloadALocalModel &&
|
||||||
|
'border-none'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => {
|
{RibbonNavMenus.filter((menu) => !!menu).map((menu, i) => {
|
||||||
|
|||||||
@ -1,32 +1,317 @@
|
|||||||
import { memo } from 'react'
|
import React, { Fragment, useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@janhq/joi'
|
import Image from 'next/image'
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
|
import { InferenceEngine } from '@janhq/core/.'
|
||||||
|
import { Button, Input, Progress, ScrollArea } from '@janhq/joi'
|
||||||
|
|
||||||
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
import { SearchIcon, DownloadCloudIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
import LogoMark from '@/containers/Brand/Logo/Mark'
|
import LogoMark from '@/containers/Brand/Logo/Mark'
|
||||||
|
import CenterPanelContainer from '@/containers/CenterPanelContainer'
|
||||||
|
|
||||||
|
import ProgressCircle from '@/containers/Loader/ProgressCircle'
|
||||||
|
|
||||||
|
import ModelLabel from '@/containers/ModelLabel'
|
||||||
|
|
||||||
import { MainViewState } from '@/constants/screens'
|
import { MainViewState } from '@/constants/screens'
|
||||||
|
|
||||||
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
import useDownloadModel from '@/hooks/useDownloadModel'
|
||||||
|
|
||||||
const EmptyModel = () => {
|
import { modelDownloadStateAtom } from '@/hooks/useDownloadState'
|
||||||
|
|
||||||
|
import { formatDownloadPercentage, toGibibytes } from '@/utils/converter'
|
||||||
|
import {
|
||||||
|
getLogoEngine,
|
||||||
|
getTitleByEngine,
|
||||||
|
localEngines,
|
||||||
|
} from '@/utils/modelEngine'
|
||||||
|
|
||||||
|
import { mainViewStateAtom } from '@/helpers/atoms/App.atom'
|
||||||
|
import {
|
||||||
|
configuredModelsAtom,
|
||||||
|
getDownloadingModelAtom,
|
||||||
|
} from '@/helpers/atoms/Model.atom'
|
||||||
|
import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
extensionHasSettings: {
|
||||||
|
name?: string
|
||||||
|
setting: string
|
||||||
|
apiKey: string
|
||||||
|
provider: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const OnDeviceStarterScreen = ({ extensionHasSettings }: Props) => {
|
||||||
|
const [searchValue, setSearchValue] = useState('')
|
||||||
|
const downloadingModels = useAtomValue(getDownloadingModelAtom)
|
||||||
|
const { downloadModel } = useDownloadModel()
|
||||||
|
const downloadStates = useAtomValue(modelDownloadStateAtom)
|
||||||
|
const setSelectedSetting = useSetAtom(selectedSettingAtom)
|
||||||
|
|
||||||
|
const configuredModels = useAtomValue(configuredModelsAtom)
|
||||||
const setMainViewState = useSetAtom(mainViewStateAtom)
|
const setMainViewState = useSetAtom(mainViewStateAtom)
|
||||||
|
|
||||||
|
const featuredModel = configuredModels.filter((x) =>
|
||||||
|
x.metadata.tags.includes('Featured')
|
||||||
|
)
|
||||||
|
|
||||||
|
const remoteModel = configuredModels.filter(
|
||||||
|
(x) => !localEngines.includes(x.engine)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredModels = configuredModels.filter((model) => {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center text-center">
|
localEngines.includes(model.engine) &&
|
||||||
<LogoMark className="mx-auto mb-4 animate-wave" width={56} height={56} />
|
model.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||||
<h1 className="text-base font-semibold">Welcome!</h1>
|
)
|
||||||
<p className="mt-1 text-[hsla(var(--text-secondary))]">
|
})
|
||||||
You need to download your first model
|
|
||||||
</p>
|
const remoteModelEngine = remoteModel.map((x) => x.engine)
|
||||||
<Button
|
const groupByEngine = remoteModelEngine.filter(function (item, index) {
|
||||||
className="mt-4"
|
if (remoteModelEngine.indexOf(item) === index) return item
|
||||||
onClick={() => setMainViewState(MainViewState.Hub)}
|
})
|
||||||
|
|
||||||
|
const itemsPerRow = 5
|
||||||
|
|
||||||
|
const getRows = (array: string[], itemsPerRow: number) => {
|
||||||
|
const rows = []
|
||||||
|
for (let i = 0; i < array.length; i += itemsPerRow) {
|
||||||
|
rows.push(array.slice(i, i + itemsPerRow))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = getRows(groupByEngine, itemsPerRow)
|
||||||
|
|
||||||
|
const [visibleRows, setVisibleRows] = useState(1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CenterPanelContainer>
|
||||||
|
<ScrollArea className="flex h-full w-full items-center">
|
||||||
|
<div className="relative mt-4 flex h-full w-full flex-col items-center justify-center">
|
||||||
|
<div className="mx-auto flex h-full w-3/4 flex-col items-center justify-center py-16 text-center">
|
||||||
|
<LogoMark
|
||||||
|
className="mx-auto mb-4 animate-wave"
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
/>
|
||||||
|
<h1 className="text-base font-semibold">Select a model to start</h1>
|
||||||
|
<div className="mt-6 w-full lg:w-1/2">
|
||||||
|
<Fragment>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={searchValue}
|
||||||
|
onChange={(e) => setSearchValue(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
prefixIcon={<SearchIcon size={16} />}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
'absolute left-0 top-10 max-h-[240px] w-full overflow-x-auto rounded-lg border border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))]',
|
||||||
|
!searchValue.length ? 'invisible' : 'visible'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Explore The Hub
|
{!filteredModels.length ? (
|
||||||
</Button>
|
<div className="p-3 text-center">
|
||||||
|
<p className="line-clamp-1 text-[hsla(var(--text-secondary))]">
|
||||||
|
No Result Found
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredModels.map((model) => {
|
||||||
|
const isDownloading = downloadingModels.some(
|
||||||
|
(md) => md.id === model.id
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="flex items-center justify-between gap-4 px-3 py-2 hover:bg-[hsla(var(--dropdown-menu-hover-bg))]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p
|
||||||
|
className={twMerge('line-clamp-1')}
|
||||||
|
title={model.name}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</p>
|
||||||
|
<ModelLabel metadata={model.metadata} compact />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-[hsla(var(--text-tertiary))]">
|
||||||
|
<span className="font-medium">
|
||||||
|
{toGibibytes(model.metadata.size)}
|
||||||
|
</span>
|
||||||
|
{!isDownloading ? (
|
||||||
|
<DownloadCloudIcon
|
||||||
|
size={18}
|
||||||
|
className="cursor-pointer text-[hsla(var(--app-link))]"
|
||||||
|
onClick={() => downloadModel(model)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
Object.values(downloadStates)
|
||||||
|
.filter((x) => x.modelId === model.id)
|
||||||
|
.map((item) => (
|
||||||
|
<ProgressCircle
|
||||||
|
key={item.modelId}
|
||||||
|
percentage={
|
||||||
|
formatDownloadPercentage(
|
||||||
|
item?.percent,
|
||||||
|
{
|
||||||
|
hidePercentage: true,
|
||||||
|
}
|
||||||
|
) as number
|
||||||
|
}
|
||||||
|
size={100}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-[hsla(var(--text-secondary))]">
|
||||||
|
On-device Models
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="cursor-pointer text-sm text-[hsla(var(--text-secondary))]"
|
||||||
|
onClick={() => {
|
||||||
|
setMainViewState(MainViewState.Hub)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
See All
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{featuredModel.slice(0, 2).map((featModel) => {
|
||||||
|
const isDownloading = downloadingModels.some(
|
||||||
|
(md) => md.id === featModel.id
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={featModel.id}
|
||||||
|
className="my-2 flex items-center justify-between gap-2 border-b border-[hsla(var(--app-border))] py-4 last:border-none"
|
||||||
|
>
|
||||||
|
<div className="w-full text-left">
|
||||||
|
<h6>{featModel.name}</h6>
|
||||||
|
<p className="mt-1 text-[hsla(var(--text-secondary))]">
|
||||||
|
{featModel.metadata.author}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading ? (
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
{Object.values(downloadStates).map((item, i) => (
|
||||||
|
<div
|
||||||
|
className="flex w-full items-center gap-2"
|
||||||
|
key={i}
|
||||||
|
>
|
||||||
|
<Progress
|
||||||
|
className="w-full"
|
||||||
|
value={
|
||||||
|
formatDownloadPercentage(item?.percent, {
|
||||||
|
hidePercentage: true,
|
||||||
|
}) as number
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between gap-x-2">
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<span className="font-medium text-[hsla(var(--primary-bg))]">
|
||||||
|
{formatDownloadPercentage(item?.percent)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
theme="ghost"
|
||||||
|
className="!bg-[hsla(var(--secondary-bg))]"
|
||||||
|
onClick={() => downloadModel(featModel)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="mb-4 mt-8 flex items-center justify-between">
|
||||||
|
<h2 className="text-[hsla(var(--text-secondary))]">
|
||||||
|
Cloud Models
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col justify-center gap-6">
|
||||||
|
{rows.slice(0, visibleRows).map((row, rowIndex) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className="my-2 flex items-center justify-normal gap-10"
|
||||||
|
>
|
||||||
|
{row.map((remoteEngine) => {
|
||||||
|
const engineLogo = getLogoEngine(
|
||||||
|
remoteEngine as InferenceEngine
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer flex-col items-center justify-center gap-4"
|
||||||
|
key={remoteEngine}
|
||||||
|
onClick={() => {
|
||||||
|
setMainViewState(MainViewState.Settings)
|
||||||
|
setSelectedSetting(
|
||||||
|
extensionHasSettings.find((x) =>
|
||||||
|
x.name?.toLowerCase().includes(remoteEngine)
|
||||||
|
)?.setting as string
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{engineLogo && (
|
||||||
|
<Image
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
src={engineLogo}
|
||||||
|
alt="Engine logo"
|
||||||
|
className="h-10 w-10 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{getTitleByEngine(
|
||||||
|
remoteEngine as InferenceEngine
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{visibleRows < rows.length && (
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleRows(visibleRows + 1)}
|
||||||
|
className="mt-4 text-[hsla(var(--text-secondary))]"
|
||||||
|
>
|
||||||
|
See More
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CenterPanelContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EmptyModel)
|
export default OnDeviceStarterScreen
|
||||||
|
|||||||
@ -11,18 +11,15 @@ import ChatItem from '../ChatItem'
|
|||||||
|
|
||||||
import LoadModelError from '../LoadModelError'
|
import LoadModelError from '../LoadModelError'
|
||||||
|
|
||||||
import EmptyModel from './EmptyModel'
|
|
||||||
import EmptyThread from './EmptyThread'
|
import EmptyThread from './EmptyThread'
|
||||||
|
|
||||||
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
|
||||||
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
|
||||||
|
|
||||||
const ChatBody = () => {
|
const ChatBody = () => {
|
||||||
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
const messages = useAtomValue(getCurrentChatMessagesAtom)
|
||||||
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
|
||||||
const loadModelError = useAtomValue(loadModelErrorAtom)
|
const loadModelError = useAtomValue(loadModelErrorAtom)
|
||||||
|
|
||||||
if (!downloadedModels.length) return <EmptyModel />
|
|
||||||
if (!messages.length) return <EmptyThread />
|
if (!messages.length) return <EmptyThread />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,17 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import { useAtomValue } from 'jotai'
|
||||||
|
|
||||||
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
|
import ThreadLeftPanel from '@/screens/Thread/ThreadLeftPanel'
|
||||||
|
|
||||||
|
import { localEngines } from '@/utils/modelEngine'
|
||||||
|
|
||||||
import ThreadCenterPanel from './ThreadCenterPanel'
|
import ThreadCenterPanel from './ThreadCenterPanel'
|
||||||
|
import OnDeviceStarterScreen from './ThreadCenterPanel/ChatBody/EmptyModel'
|
||||||
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
|
import ModalCleanThread from './ThreadLeftPanel/ModalCleanThread'
|
||||||
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
|
import ModalDeleteThread from './ThreadLeftPanel/ModalDeleteThread'
|
||||||
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
|
import ModalEditTitleThread from './ThreadLeftPanel/ModalEditTitleThread'
|
||||||
import ThreadRightPanel from './ThreadRightPanel'
|
import ThreadRightPanel from './ThreadRightPanel'
|
||||||
|
|
||||||
|
import { extensionManager } from '@/extension'
|
||||||
|
import { downloadedModelsAtom } from '@/helpers/atoms/Model.atom'
|
||||||
|
import { threadsAtom } from '@/helpers/atoms/Thread.atom'
|
||||||
|
|
||||||
const ThreadScreen = () => {
|
const ThreadScreen = () => {
|
||||||
|
const downloadedModels = useAtomValue(downloadedModelsAtom)
|
||||||
|
const threads = useAtomValue(threadsAtom)
|
||||||
|
|
||||||
|
const isDownloadALocalModel = downloadedModels.some((x) =>
|
||||||
|
localEngines.includes(x.engine)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [extensionHasSettings, setExtensionHasSettings] = useState<
|
||||||
|
{ name?: string; setting: string; apiKey: string; provider: string }[]
|
||||||
|
>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getAllSettings = async () => {
|
||||||
|
const extensionsMenu: {
|
||||||
|
name?: string
|
||||||
|
setting: string
|
||||||
|
apiKey: string
|
||||||
|
provider: string
|
||||||
|
}[] = []
|
||||||
|
const extensions = extensionManager.getAll()
|
||||||
|
|
||||||
|
for (const extension of extensions) {
|
||||||
|
if (typeof extension.getSettings === 'function') {
|
||||||
|
const settings = await extension.getSettings()
|
||||||
|
|
||||||
|
if (
|
||||||
|
(settings && settings.length > 0) ||
|
||||||
|
(await extension.installationState()) !== 'NotRequired'
|
||||||
|
) {
|
||||||
|
extensionsMenu.push({
|
||||||
|
name: extension.productName,
|
||||||
|
setting: extension.name,
|
||||||
|
apiKey:
|
||||||
|
'apiKey' in extension && typeof extension.apiKey === 'string'
|
||||||
|
? extension.apiKey
|
||||||
|
: '',
|
||||||
|
provider:
|
||||||
|
'provider' in extension &&
|
||||||
|
typeof extension.provider === 'string'
|
||||||
|
? extension.provider
|
||||||
|
: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setExtensionHasSettings(extensionsMenu)
|
||||||
|
}
|
||||||
|
getAllSettings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isAnyRemoteModelConfigured = extensionHasSettings.some(
|
||||||
|
(x) => x.apiKey.length > 1
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
|
<div className="relative flex h-full w-full flex-1 overflow-x-hidden">
|
||||||
|
{!isAnyRemoteModelConfigured &&
|
||||||
|
!isDownloadALocalModel &&
|
||||||
|
!threads.length ? (
|
||||||
|
<>
|
||||||
|
<OnDeviceStarterScreen extensionHasSettings={extensionHasSettings} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<ThreadLeftPanel />
|
<ThreadLeftPanel />
|
||||||
<ThreadCenterPanel />
|
<ThreadCenterPanel />
|
||||||
<ThreadRightPanel />
|
<ThreadRightPanel />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Showing variant modal action for thread screen */}
|
{/* Showing variant modal action for thread screen */}
|
||||||
<ModalEditTitleThread />
|
<ModalEditTitleThread />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user