improve mobile

This commit is contained in:
Thomas G. Lopes 2025-06-19 17:18:04 +01:00
parent c2cc437061
commit b468afdc8c
10 changed files with 78 additions and 31 deletions

View file

@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n \"convex,vite\" -c \"blue.bold,green.bold\" \"convex dev\" \"vite dev\"", "dev": "concurrently -n \"convex,vite\" -c \"blue.bold,green.bold\" \"convex dev\" \"vite dev\"",
"dev:host": "concurrently -n \"convex,vite\" -c \"blue.bold,green.bold\" \"convex dev\" \"vite dev --host\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",

View file

@ -54,7 +54,7 @@
} }
.dark { .dark {
--background: oklch(0.2409 0.0201 307.5346); --background: oklch(0.2409 0.0201 267.5346);
--foreground: oklch(0.8398 0.0387 309.5391); --foreground: oklch(0.8398 0.0387 309.5391);
--card: oklch(0.2803 0.0232 307.5413); --card: oklch(0.2803 0.0232 307.5413);
--card-foreground: oklch(0.8456 0.0302 341.4597); --card-foreground: oklch(0.8456 0.0302 341.4597);
@ -211,13 +211,38 @@
--shadow-2xl: var(--shadow-2xl); --shadow-2xl: var(--shadow-2xl);
} }
@utility fill-device {
height: 100vh;
height: 100dvh; /* Dynamic viewport height - newer browsers */
/* Alternative: Use env() for safe areas */
min-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom));
/* Ensure full width */
width: 100vw;
width: 100dvw; /* Dynamic viewport width */
/* Remove default margins/padding */
margin: 0;
padding: 0;
overflow-x: hidden;
}
@utility fill-device-height {
height: 100svh;
/* min-height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); */
}
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground bg-noise; @apply bg-background text-foreground bg-noise fill-device;
position: fixed;
overflow: clip;
} }
} }

View file

@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<title>thom.chat</title> <title>thom.chat</title>
%sveltekit.head% %sveltekit.head%
</head> </head>

View file

@ -5,6 +5,6 @@
let { class: className, children, ...rest }: HTMLAttributes<HTMLDivElement> = $props(); let { class: className, children, ...rest }: HTMLAttributes<HTMLDivElement> = $props();
</script> </script>
<div {...rest} class={cn('bg-background bg-noise col-start-2 h-screen', className)}> <div {...rest} class={cn('bg-background bg-noise fill-device-height col-start-2', className)}>
{@render children?.()} {@render children?.()}
</div> </div>

View file

@ -12,7 +12,7 @@
<div <div
{...rest} {...rest}
class={cn( class={cn(
'bg-sidebar border-sidebar-border col-start-1 h-screen w-[--sidebar-width] border-r', 'bg-sidebar border-sidebar-border fill-device-height col-start-1 w-[--sidebar-width] border-r',
className className
)} )}
> >

View file

@ -40,6 +40,8 @@
import { callGenerateMessage } from '../api/generate-message/call.js'; import { callGenerateMessage } from '../api/generate-message/call.js';
import ModelPicker from './model-picker.svelte'; import ModelPicker from './model-picker.svelte';
import SearchModal from './search-modal.svelte'; import SearchModal from './search-modal.svelte';
import { shortcut } from '$lib/actions/shortcut.svelte.js';
import { mergeAttrs } from 'melt';
const client = useConvexClient(); const client = useConvexClient();
@ -378,9 +380,13 @@
<title>Chat | thom.chat</title> <title>Chat | thom.chat</title>
</svelte:head> </svelte:head>
<svelte:window
use:shortcut={[{ ctrl: true, key: 'd', callback: () => scrollState.scrollToBottom() }]}
/>
<Sidebar.Root <Sidebar.Root
bind:open={sidebarOpen} bind:open={sidebarOpen}
class="h-screen overflow-clip" class="fill-device-height overflow-clip"
{...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}} {...currentModelSupportsImages ? omit(fileUpload.dropzone, ['onclick']) : {}}
> >
<AppSidebar bind:searchModalOpen /> <AppSidebar bind:searchModalOpen />
@ -443,7 +449,7 @@
<LightSwitch variant="ghost" class="size-8" /> <LightSwitch variant="ghost" class="size-8" />
</div> </div>
<div class="relative"> <div class="relative">
<div bind:this={conversationList} class="h-screen overflow-y-auto"> <div bind:this={conversationList} class="fill-device-height overflow-y-auto">
<div <div
class={cn('mx-auto flex max-w-3xl flex-col', { class={cn('mx-auto flex max-w-3xl flex-col', {
'pt-10': page.url.pathname !== '/chat', 'pt-10': page.url.pathname !== '/chat',
@ -452,19 +458,26 @@
> >
{@render children()} {@render children()}
</div> </div>
<Button <Tooltip placement="top">
onclick={() => scrollState.scrollToBottom()} {#snippet trigger(tooltip)}
variant="secondary" <Button
size="sm" onclick={() => scrollState.scrollToBottom()}
class={[ variant="secondary"
'text-muted-foreground !border-border absolute bottom-0 left-1/2 z-10 -translate-x-1/2 rounded-full !border !pl-3 text-xs transition', size="sm"
notAtBottom.current ? 'opacity-100' : 'pointer-events-none scale-95 opacity-0', class={[
]} 'text-muted-foreground !border-border absolute bottom-0 left-1/2 z-10 -translate-x-1/2 rounded-full !border !pl-3 text-xs transition',
style="bottom: {wrapperSize.height + 5}px;" notAtBottom.current ? 'opacity-100' : 'pointer-events-none scale-95 opacity-0',
> ]}
Scroll to bottom {...mergeAttrs(tooltip.trigger, {
<ChevronDownIcon class="inline" /> style: `bottom: ${wrapperSize.height + 5}px;`,
</Button> })}
>
Scroll to bottom
<ChevronDownIcon class="inline" />
</Button>
{/snippet}
{cmdOrCtrl} + D
</Tooltip>
</div> </div>
<div <div
@ -572,10 +585,10 @@
{...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])} {...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])}
bind:this={textarea} bind:this={textarea}
disabled={textareaDisabled} disabled={textareaDisabled}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[80px] w-full resize-none !overflow-y-auto bg-transparent px-3 text-base leading-6 outline-none disabled:cursor-not-allowed disabled:opacity-50" class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[60px] w-full resize-none !overflow-y-auto bg-transparent px-3 text-base leading-6 outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-[80px]"
placeholder={isGenerating placeholder={isGenerating
? 'Generating response...' ? 'Generating response...'
: 'Type your message here... Tag rules with @'} : 'Type your message here, tag rules with @'}
name="message" name="message"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !popover.open) { if (e.key === 'Enter' && !e.shiftKey && !popover.open) {
@ -634,23 +647,23 @@
{isGenerating ? 'Stop generation' : 'Send message'} {isGenerating ? 'Stop generation' : 'Send message'}
</Tooltip> </Tooltip>
</div> </div>
<div class="flex flex-col items-start gap-2 pr-2 sm:flex-row sm:items-center"> <div class="flex flex-row flex-wrap items-center gap-2 pr-2">
<ModelPicker onlyImageModels={selectedImages.length > 0} /> <ModelPicker onlyImageModels={selectedImages.length > 0} />
<button <button
type="button" type="button"
class={cn( class={cn(
'border-border flex items-center gap-1 rounded-full border px-2 py-1 text-xs transition-colors', 'border-border flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors sm:px-2',
settings.webSearchEnabled ? 'bg-accent/50' : 'hover:bg-accent/20' settings.webSearchEnabled ? 'bg-accent/50' : 'hover:bg-accent/20'
)} )}
onclick={() => (settings.webSearchEnabled = !settings.webSearchEnabled)} onclick={() => (settings.webSearchEnabled = !settings.webSearchEnabled)}
> >
<SearchIcon class="!size-3" /> <SearchIcon class="!size-3" />
<span class="whitespace-nowrap">Web search</span> <span class="hidden whitespace-nowrap sm:inline">Web search</span>
</button> </button>
{#if currentModelSupportsImages} {#if currentModelSupportsImages}
<button <button
type="button" type="button"
class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-2 py-1 text-xs transition-colors disabled:opacity-50" class="border-border hover:bg-accent/20 flex items-center gap-1 rounded-full border px-1 py-1 text-xs transition-colors disabled:opacity-50 sm:px-2"
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={isUploading} disabled={isUploading}
> >
@ -661,7 +674,7 @@
{:else} {:else}
<ImageIcon class="!size-3" /> <ImageIcon class="!size-3" />
{/if} {/if}
<span class="whitespace-nowrap">Attach image</span> <span class="hidden whitespace-nowrap sm:inline">Attach image</span>
</button> </button>
{/if} {/if}
</div> </div>

View file

@ -214,7 +214,7 @@
<button <button
{...popover.trigger} {...popover.trigger}
class={cn( class={cn(
'ring-offset-background focus:ring-ring flex w-full items-center justify-between rounded-lg px-2 py-1 text-xs transition hover:text-white focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50', 'ring-offset-background focus:ring-ring flex items-center justify-between rounded-lg px-2 py-1 text-xs transition hover:text-white focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
aria-expanded={open} aria-expanded={open}

View file

@ -77,7 +77,7 @@
} }
</script> </script>
<svelte:window use:shortcut={{ ctrl: true, key: 'k', callback: () => (open = true) }} /> <svelte:window use:shortcut={[{ ctrl: true, key: 'k', callback: () => (open = true) }]} />
<Modal bind:open> <Modal bind:open>
<div class="space-y-4"> <div class="space-y-4">
@ -121,7 +121,7 @@
</div> </div>
{:else if search.data?.length} {:else if search.data?.length}
<div class="max-h-96 space-y-2 overflow-y-auto"> <div class="max-h-96 space-y-2 overflow-y-auto">
{#each search.data as { conversation, messages, score, titleMatch }, index} {#each search.data as { conversation, messages, titleMatch }, index}
<div <div
data-result-index={index} data-result-index={index}
class="border-border flex cursor-pointer items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors {index === class="border-border flex cursor-pointer items-center justify-between gap-2 rounded-lg border px-3 py-2 transition-colors {index ===

View file

@ -34,7 +34,7 @@
<meta name="description" content="A shared conversation from thom.chat" /> <meta name="description" content="A shared conversation from thom.chat" />
</svelte:head> </svelte:head>
<div class="min-h-screen"> <div class="fill-device-height">
<!-- Header --> <!-- Header -->
<header <header
class="border-border bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 border-b backdrop-blur" class="border-border bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 border-b backdrop-blur"

View file

@ -4,6 +4,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
import Icons from 'unplugin-icons/vite'; import Icons from 'unplugin-icons/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
const isDev = process.env.NODE_ENV === 'development';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
@ -37,4 +39,7 @@ export default defineConfig({
}, },
], ],
}, },
server: {
allowedHosts: isDev ? true : undefined,
},
}); });