fix(frontend): improve ai chat ui (#7648)

* update chat to brand guidelines

* remove useless footer

* restore conversation count

* nit

* nit
This commit is contained in:
Guilhem
2026-01-21 19:38:28 +00:00
committed by GitHub
parent 761bba87e3
commit c95e6f7354
4 changed files with 125 additions and 111 deletions

View File

@@ -16,6 +16,8 @@
noBorder?: boolean
extraRowClasses?: { bgSelected: string; bgHover: string; class: string }
neverShowLoader?: boolean
preventXOverflow?: boolean
customRow?: import('svelte').Snippet<[any]>
columns?: import('svelte').Snippet
extra_row?: import('svelte').Snippet<[any]>
children?: import('svelte').Snippet<[any]>
@@ -36,6 +38,8 @@
class: ''
},
neverShowLoader = false,
preventXOverflow = false,
customRow,
columns,
extra_row,
children,
@@ -167,34 +171,41 @@
{rounded}
{noBorder}
{neverShowLoader}
{preventXOverflow}
>
{@render columns?.()}
<tbody class="h-full w-full">
<Row
on:click={() => dispatch('select', 'extraRow')}
class={twMerge(
extraRowClasses.class,
selectedItemId === 'extraRow' ? extraRowClasses.bgSelected : extraRowClasses.bgHover,
'cursor-pointer rounded-md'
)}
on:hover={(e) => (hovered = e.detail ? 'extraRow' : undefined)}
>
{@render extra_row?.({ hover: hovered === 'extraRow' })}
</Row>
{#if extra_row}
<Row
on:click={() => dispatch('select', 'extraRow')}
class={twMerge(
extraRowClasses.class,
selectedItemId === 'extraRow' ? extraRowClasses.bgSelected : extraRowClasses.bgHover,
'cursor-pointer rounded-md'
)}
on:hover={(e) => (hovered = e.detail ? 'extraRow' : undefined)}
>
{@render extra_row?.({ hover: hovered === 'extraRow' })}
</Row>
{/if}
{#each items ?? [] as item, index}
{@const hover = item.id === hovered}
<Row
on:click={() => dispatch('select', item)}
class={twMerge(
selectedItemId === item.id ? 'bg-surface-selected' : 'hover:bg-surface-hover',
'cursor-pointer rounded-md',
item.isNew && index === 0 ? 'animate-slideIn' : 'group'
)}
on:hover={(e) => (hovered = e.detail ? item.id : undefined)}
>
{@render children?.({ item, hover })}
</Row>
{#if customRow}
{@render customRow?.({ item, hover })}
{:else}
<Row
on:click={() => dispatch('select', item)}
class={twMerge(
selectedItemId === item.id ? 'bg-surface-selected' : 'hover:bg-surface-hover',
'cursor-pointer rounded-md',
item.isNew && index === 0 ? 'animate-slideIn' : 'group'
)}
on:hover={(e) => (hovered = e.detail ? item.id : undefined)}
>
{@render children?.({ item, hover })}
</Row>
{/if}
{/each}
</tbody>

View File

@@ -6,9 +6,10 @@
count: number | undefined
small?: boolean
alwaysVisible?: boolean
class?: string
}
let { count, small = true, alwaysVisible = false }: Props = $props()
let { count, small = true, alwaysVisible = false, class: classNames = '' }: Props = $props()
</script>
{#if count === undefined || count > 0 || alwaysVisible}
@@ -25,7 +26,8 @@
// Special case for always visible
alwaysVisible && small ? 'h-3 w-3 text-[8px] -right-0.5 -top-0.5' : '',
alwaysVisible && !small ? 'h-4 w-4 text-xs -right-1 -top-1' : ''
alwaysVisible && !small ? 'h-4 w-4 text-xs -right-1 -top-1' : '',
classNames
)}
>
{#if count === undefined}
@@ -34,4 +36,4 @@
<p>{count}</p>
{/if}
</div>
{/if}
{/if}

View File

@@ -1,18 +1,12 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
MessageCircle,
Plus,
Trash2,
PanelLeftClose,
PanelLeftOpen,
Loader2
} from 'lucide-svelte'
import { MessageCircle, Plus, Trash2, PanelLeftClose, PanelLeftOpen } from 'lucide-svelte'
import { type FlowConversation } from '$lib/gen'
import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { twMerge } from 'tailwind-merge'
import { FlowChatManager } from './FlowChatManager.svelte'
import { fade } from 'svelte/transition'
interface Props {
manager: FlowChatManager
@@ -26,35 +20,37 @@
</script>
<div
class="flex flex-col h-full bg-surface border-r border-gray-200 dark:border-gray-700 transition-all duration-300 {manager.isSidebarExpanded
class="flex flex-col h-full bg-surface border-r transition-all duration-300 {manager.isSidebarExpanded
? 'w-60'
: 'w-16'}"
: 'w-[44px]'}"
>
<!-- Header -->
<div class="flex-shrink-0 p-2 border-b border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-2">
<div class="flex-shrink-0 border-b">
<div class="flex flex-col gap-2 p-1">
<Button
size="sm"
color="light"
startIcon={{ icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen }}
onclick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
unifiedSize="md"
variant="subtle"
startIcon={{
icon: manager.isSidebarExpanded ? PanelLeftClose : PanelLeftOpen,
classes: 'ml-[2px]'
}}
onClick={() => (manager.isSidebarExpanded = !manager.isSidebarExpanded)}
iconOnly={!manager.isSidebarExpanded}
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
label="Conversations"
btnClasses={'justify-start transition-all duration-150'}
title="Conversations"
>
Conversations
<div transition:fade={{ duration: 100 }}> Conversations </div>
</Button>
<Button
size="sm"
color="light"
startIcon={{ icon: Plus }}
onclick={() => manager.createConversation({ clearMessages: true })}
unifiedSize="md"
variant="subtle"
startIcon={{ icon: Plus, classes: 'ml-[2px]' }}
onClick={() => manager.createConversation({ clearMessages: true })}
title="Start new conversation"
iconOnly={!manager.isSidebarExpanded}
btnClasses={manager.isSidebarExpanded ? '!justify-start' : ''}
label="New chat"
btnClasses={'justify-start transition-all duration-150 whitespace-nowrap'}
>
New chat
<div transition:fade={{ duration: 100 }}> New chat </div>
</Button>
</div>
</div>
@@ -62,71 +58,79 @@
<!-- Conversations List -->
{#if !manager.isSidebarExpanded}
<!-- Collapsed state - show single chat icon with badge -->
<div class="p-2 flex flex-col items-center mt-2">
<button
class="relative w-[23px] h-[23px] rounded-md center-center hover:bg-surface-hover transition-all duration-100 text-secondary hover:text-primary group"
onclick={() => (manager.isSidebarExpanded = true)}
<div class="p-1">
<Button
unifiedSize="md"
startIcon={{ icon: MessageCircle }}
onClick={() => (manager.isSidebarExpanded = true)}
title="{manager.conversations.length} conversation{manager.conversations.length !== 1
? 's'
: ''}"
variant="subtle"
btnClasses="w-fit px-2 relative"
>
<MessageCircle size={16} />
<CountBadge count={manager.conversations.length} small={true} alwaysVisible={true} />
</button>
<CountBadge
count={manager.conversations.length}
small
alwaysVisible={true}
class="right-[3px] top-[3px]"
/>
</Button>
</div>
{/if}
<!-- Always mount InfiniteList, but hide it when collapsed -->
<div class="flex-1 overflow-hidden" class:hidden={!manager.isSidebarExpanded}>
<div
class="flex-1 overflow-hidden transition-all duration-150 p-1"
class:hidden={!manager.isSidebarExpanded}
>
<InfiniteList
bind:this={manager.conversationListComponent}
bind:items={manager.conversations}
selectedItemId={manager.selectedConversationId}
noBorder={true}
rounded={false}
preventXOverflow={true}
>
{#snippet children({ item: conversation, hover })}
<div
class={twMerge(
'w-full p-1',
manager.selectedConversationId === conversation.id
? 'bg-blue-200/30 text-blue-500 dark:bg-blue-600/30 text-blue-400'
: ''
)}
>
<Button
color="transparent"
size="xs"
onclick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
>
<span class="flex-1 text-left text-sm font-medium text-primary truncate">
{getConversationTitle(conversation)}
</span>
<button
class="ml-2 p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500 transition-all {hover ||
manager.deletingConversationId === conversation.id
? 'opacity-100'
: 'opacity-0'}"
disabled={manager.deletingConversationId === conversation.id}
onclick={(e) => {
e.stopPropagation()
if (conversation.isDraft) {
// just remove first conversation as it is the draft
manager.conversations = [...manager.conversations.slice(1)]
} else {
manager.conversationListComponent?.deleteItem(conversation.id)
}
}}
title="Delete conversation"
{#snippet customRow({ item: conversation, hover })}
{#if manager.isSidebarExpanded}
<div class={twMerge('w-full pb-1')} transition:fade={{ duration: 100, delay: 30 }}>
<Button
unifiedSize="md"
variant="subtle"
onClick={() => manager.selectConversation(conversation.id, conversation.isDraft)}
selected={manager.selectedConversationId === conversation.id}
btnClasses="transition-all duration-150 group"
>
{#if manager.deletingConversationId === conversation.id}
<Loader2 size={14} class="animate-spin" />
{:else}
<Trash2 size={14} />
{/if}
</button>
</Button>
</div>
<span class="flex-1 text-left truncate">
{getConversationTitle(conversation)}
</span>
<Button
wrapperClasses={twMerge(
'ml-2 transition-all duration-100 opacity-0 group-hover:opacity-100',
manager.deletingConversationId === conversation.id ? 'opacity-100' : ' '
)}
disabled={manager.deletingConversationId === conversation.id}
onClick={(e) => {
e?.stopPropagation()
if (conversation.isDraft) {
// just remove first conversation as it is the draft
manager.conversations = [...manager.conversations.slice(1)]
} else {
manager.conversationListComponent?.deleteItem(conversation.id)
}
}}
title="Delete conversation"
destructive
unifiedSize="xs"
variant="subtle"
loading={manager.deletingConversationId === conversation.id}
iconOnly
startIcon={{ icon: Trash2 }}
/>
</Button>
</div>
{/if}
{/snippet}
{#snippet empty()}
@@ -136,13 +140,4 @@
{/snippet}
</InfiniteList>
</div>
{#if manager.isSidebarExpanded}
<!-- Footer -->
<div class="flex-shrink-0 p-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-primary">
{manager.conversations.length} conversation{manager.conversations.length !== 1 ? 's' : ''}
</p>
</div>
{/if}
</div>

View File

@@ -36,6 +36,7 @@
loading?: boolean
loadingMore?: boolean
containerClass?: string
preventXOverflow?: boolean
children?: import('svelte').Snippet
emptyMessage?: import('svelte').Snippet
}
@@ -62,7 +63,8 @@
loadingMore = false,
containerClass = '',
children,
emptyMessage
emptyMessage,
preventXOverflow = false
}: Props = $props()
setContext<DatatableContext>('datatable', {
size
@@ -127,7 +129,11 @@
bind:clientHeight={tableHeight}
>
<List justify="between" gap="none" hFull={true}>
<div class="w-full overflow-auto h-fit" bind:this={tableContainer} onscroll={handleScroll}>
<div
class={twMerge('w-full overflow-auto h-fit', preventXOverflow ? 'overflow-x-hidden' : '')}
bind:this={tableContainer}
onscroll={handleScroll}
>
<table class={tableFixed ? 'table-fixed w-full' : 'min-w-full'}>
{@render children?.()}
</table>