enhancement: add setting chat width container (#5289)

* enhancement: add setting conversation width

* enahncement: cleanup log and change improve accesibility

* enahcement: move const beta version
This commit is contained in:
Faisal Amir 2025-06-16 15:02:43 +07:00 committed by GitHub
parent bea806c26c
commit da2f97c227
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 202 additions and 105 deletions

View File

@ -0,0 +1,13 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-main-view-fg/10', className)}
{...props}
/>
)
}
export { Skeleton }

View File

@ -21,7 +21,6 @@ import {
IconTool, IconTool,
IconCodeCircle2, IconCodeCircle2,
IconPlayerStopFilled, IconPlayerStopFilled,
IconBrandSpeedtest,
IconX, IconX,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -45,12 +44,7 @@ type ChatInputProps = {
initialMessage?: boolean initialMessage?: boolean
} }
const ChatInput = ({ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
model,
className,
showSpeedToken = true,
initialMessage,
}: ChatInputProps) => {
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false) const [isFocused, setIsFocused] = useState(false)
const [rows, setRows] = useState(1) const [rows, setRows] = useState(1)
@ -60,7 +54,7 @@ const ChatInput = ({
const { currentThreadId } = useThreads() const { currentThreadId } = useThreads()
const { t } = useTranslation() const { t } = useTranslation()
const { spellCheckChatInput } = useGeneralSetting() const { spellCheckChatInput } = useGeneralSetting()
const { tokenSpeed } = useAppState()
const { showModal, PromiseModal: OutOfContextModal } = const { showModal, PromiseModal: OutOfContextModal } =
useOutOfContextPromiseModal() useOutOfContextPromiseModal()
const maxRows = 10 const maxRows = 10
@ -559,15 +553,6 @@ const ChatInput = ({
</TooltipProvider> </TooltipProvider>
)} )}
</div> </div>
{showSpeedToken && (
<div className="flex items-center gap-1 text-main-view-fg/60 text-xs">
<IconBrandSpeedtest size={18} />
<span>
{Math.round(tokenSpeed?.tokenSpeed ?? 0)} tokens/sec
</span>
</div>
)}
</div> </div>
{streamingContent ? ( {streamingContent ? (

View File

@ -0,0 +1,61 @@
import { Skeleton } from '@/components/ui/skeleton'
import { useAppearance } from '@/hooks/useAppearance'
import { cn } from '@/lib/utils'
import { IconCircleCheckFilled } from '@tabler/icons-react'
export function ChatWidthSwitcher() {
const { chatWidth, setChatWidth } = useAppearance()
return (
<div className="flex gap-4">
<button
className={cn(
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer',
chatWidth === 'compact' && 'border-accent'
)}
onClick={() => setChatWidth('compact')}
>
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">Compact Width</span>
{chatWidth === 'compact' && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
</div>
<div className="overflow-auto p-2">
<div className="flex flex-col px-10 gap-2 mt-2">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center">
<span className="text-main-view-fg/50">Ask me anything...</span>
</div>
</div>
</div>
</button>
<button
className={cn(
'w-full overflow-hidden border border-main-view-fg/10 rounded-md my-2 pb-2 cursor-pointer',
chatWidth === 'full' && 'border-accent'
)}
onClick={() => setChatWidth('full')}
>
<div className="flex items-center justify-between px-4 py-2 bg-main-view-fg/10">
<span className="font-medium text-xs font-sans">Full Width</span>
{chatWidth === 'full' && (
<IconCircleCheckFilled className="size-4 text-accent" />
)}
</div>
<div className="overflow-auto p-2">
<div className="flex flex-col gap-2 mt-2">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="bg-main-view-fg/10 h-8 px-4 w-full flex-shrink-0 border-none resize-none outline-0 rounded-2xl flex items-center">
<span className="text-main-view-fg/50">Ask me anything...</span>
</div>
</div>
</div>
</button>
</div>
)
}

View File

@ -359,97 +359,98 @@ export const ThreadContent = memo(
{!isToolCalls && ( {!isToolCalls && (
<div className="flex items-center gap-2 mt-2 text-main-view-fg/60 text-xs"> <div className="flex items-center gap-2 mt-2 text-main-view-fg/60 text-xs">
<div <div className={cn('flex items-center gap-2')}>
className={cn( <div
'flex items-center gap-2', className={cn(
item.isLastMessage && 'flex items-center gap-2',
streamingContent && item.isLastMessage && streamingContent && 'hidden'
'opacity-0 visibility-hidden pointer-events-none' )}
)} >
> <CopyButton text={item.content?.[0]?.text.value || ''} />
<CopyButton text={item.content?.[0]?.text.value || ''} />
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
removeMessage()
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Delete</p>
</TooltipContent>
</Tooltip>
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Metadata</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Message Metadata</DialogTitle>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={JSON.stringify(
item.metadata || {},
null,
2
)}
language="json"
readOnly
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
height: '100%',
}}
className="w-full h-full !text-sm"
/>
</div>
</div>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
{item.isLastMessage && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative" className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={regenerate} onClick={() => {
removeMessage()
}}
> >
<IconRefresh size={16} /> <IconTrash size={16} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Regenerate</p> <p>Delete</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} <Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Metadata</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Message Metadata</DialogTitle>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={JSON.stringify(
item.metadata || {},
null,
2
)}
language="json"
readOnly
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
height: '100%',
}}
className="w-full h-full !text-sm"
/>
</div>
</div>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
{item.isLastMessage && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={regenerate}
>
<IconRefresh size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Regenerate</p>
</TooltipContent>
</Tooltip>
)}
</div>
<TokenSpeedIndicator <TokenSpeedIndicator
streaming={Boolean(item.isLastMessage && streamingContent)}
metadata={item.metadata} metadata={item.metadata}
/> />
</div> </div>

View File

@ -1,19 +1,28 @@
import { IconBrandSpeedtest } from '@tabler/icons-react' import { useAppState } from '@/hooks/useAppState'
import { Gauge } from 'lucide-react'
interface TokenSpeedIndicatorProps { interface TokenSpeedIndicatorProps {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
streaming?: boolean
} }
export const TokenSpeedIndicator = ({ export const TokenSpeedIndicator = ({
metadata metadata,
streaming,
}: TokenSpeedIndicatorProps) => { }: TokenSpeedIndicatorProps) => {
const persistedTokenSpeed = (metadata?.tokenSpeed as { tokenSpeed: number })?.tokenSpeed const { tokenSpeed } = useAppState()
const persistedTokenSpeed = (metadata?.tokenSpeed as { tokenSpeed: number })
?.tokenSpeed
return ( return (
<div className="flex items-center gap-1 text-main-view-fg/60 text-xs"> <div className="flex items-center gap-1 text-main-view-fg/60 text-xs">
<IconBrandSpeedtest size={16} /> <Gauge size={16} />
<span> <span>
{Math.round(persistedTokenSpeed)} tokens/sec {Math.round(
streaming ? Number(tokenSpeed?.tokenSpeed) : persistedTokenSpeed
)}
&nbsp;tokens/sec
</span> </span>
</div> </div>
) )

View File

@ -22,8 +22,8 @@ const DialogAppUpdater = () => {
setRemindMeLater(true) setRemindMeLater(true)
} }
const beta = VERSION.includes('beta')
const nightly = VERSION.includes('-') const nightly = VERSION.includes('-')
const beta = VERSION.includes('beta')
const { release, fetchLatestRelease } = useReleaseNotes() const { release, fetchLatestRelease } = useReleaseNotes()

View File

@ -6,8 +6,10 @@ import { rgb, oklch, formatCss } from 'culori'
import { useTheme } from './useTheme' import { useTheme } from './useTheme'
export type FontSize = '14px' | '15px' | '16px' | '18px' export type FontSize = '14px' | '15px' | '16px' | '18px'
export type ChatWidth = 'full' | 'compact'
interface AppearanceState { interface AppearanceState {
chatWidth: ChatWidth
fontSize: FontSize fontSize: FontSize
appBgColor: RgbaColor appBgColor: RgbaColor
appMainViewBgColor: RgbaColor appMainViewBgColor: RgbaColor
@ -19,6 +21,7 @@ interface AppearanceState {
appAccentTextColor: string appAccentTextColor: string
appDestructiveTextColor: string appDestructiveTextColor: string
appLeftPanelTextColor: string appLeftPanelTextColor: string
setChatWidth: (size: ChatWidth) => void
setFontSize: (size: FontSize) => void setFontSize: (size: FontSize) => void
setAppBgColor: (color: RgbaColor) => void setAppBgColor: (color: RgbaColor) => void
setAppMainViewBgColor: (color: RgbaColor) => void setAppMainViewBgColor: (color: RgbaColor) => void
@ -129,6 +132,7 @@ export const useAppearance = create<AppearanceState>()(
persist( persist(
(set) => { (set) => {
return { return {
chatWidth: 'compact',
fontSize: defaultFontSize, fontSize: defaultFontSize,
appBgColor: defaultAppBgColor, appBgColor: defaultAppBgColor,
appMainViewBgColor: defaultAppMainViewBgColor, appMainViewBgColor: defaultAppMainViewBgColor,
@ -270,6 +274,10 @@ export const useAppearance = create<AppearanceState>()(
}) })
}, },
setChatWidth: (value: ChatWidth) => {
set({ chatWidth: value })
},
setFontSize: (size: FontSize) => { setFontSize: (size: FontSize) => {
// Update CSS variable // Update CSS variable
document.documentElement.style.setProperty('--font-size-base', size) document.documentElement.style.setProperty('--font-size-base', size)

View File

@ -18,6 +18,7 @@ import CodeBlockStyleSwitcher from '@/containers/CodeBlockStyleSwitcher'
import { LineNumbersSwitcher } from '@/containers/LineNumbersSwitcher' import { LineNumbersSwitcher } from '@/containers/LineNumbersSwitcher'
import { CodeBlockExample } from '@/containers/CodeBlockExample' import { CodeBlockExample } from '@/containers/CodeBlockExample'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ChatWidthSwitcher } from '@/containers/ChatWidthSwitcher'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.appearance as any)({ export const Route = createFileRoute(route.settings.appearance as any)({
@ -98,6 +99,15 @@ function Appareances() {
/> />
</Card> </Card>
{/* Chat Message */}
<Card>
<CardItem
title="Chat Width"
description="Choose the width of the chat area to customize your conversation view."
/>
<ChatWidthSwitcher />
</Card>
{/* Codeblock */} {/* Codeblock */}
<Card> <Card>
<CardItem <CardItem

View File

@ -35,7 +35,7 @@ function ThreadDetail() {
const { setCurrentAssistant, assistants } = useAssistant() const { setCurrentAssistant, assistants } = useAssistant()
const { setMessages } = useMessages() const { setMessages } = useMessages()
const { streamingContent } = useAppState() const { streamingContent } = useAppState()
const { appMainViewBgColor } = useAppearance() const { appMainViewBgColor, chatWidth } = useAppearance()
const { messages } = useMessages( const { messages } = useMessages(
useShallow((state) => ({ useShallow((state) => ({
@ -213,7 +213,12 @@ function ThreadDetail() {
'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3' 'flex flex-col h-full w-full overflow-auto px-4 pt-4 pb-3'
)} )}
> >
<div className="w-4/6 mx-auto flex max-w-full flex-col grow"> <div
className={cn(
'w-4/6 mx-auto flex max-w-full flex-col grow',
chatWidth === 'compact' ? 'w-4/6' : 'w-full'
)}
>
{messages && {messages &&
messages.map((item, index) => { messages.map((item, index) => {
// Only pass isLastMessage to the last message in the array // Only pass isLastMessage to the last message in the array
@ -247,7 +252,12 @@ function ThreadDetail() {
<StreamingContent threadId={threadId} /> <StreamingContent threadId={threadId} />
</div> </div>
</div> </div>
<div className="w-4/6 mx-auto pt-2 pb-3 shrink-0 relative"> <div
className={cn(
' mx-auto pt-2 pb-3 shrink-0 relative',
chatWidth === 'compact' ? 'w-4/6' : 'w-full px-3'
)}
>
<div <div
className={cn( className={cn(
'absolute z-0 -top-6 h-8 py-1 flex w-full justify-center pointer-events-none opacity-0 visibility-hidden', 'absolute z-0 -top-6 h-8 py-1 flex w-full justify-center pointer-events-none opacity-0 visibility-hidden',