ui: New Logo + Navigation cleanup & Mobile UI/UX improvements (#24897)

* chore: `npm audit fix --force`

* feat: Update sidebar toggle to use Logo

* refactor: Clean up favicon SVG

* feat: Refactor logo component and implement theme-aware favicon generation

* feat: Add configurable padding to generated PWA assets

* test: Add unit tests for writeThemeFavicons

* refactor: Componentization

* feat: WIP

* feat: WIP

* feat: WIP

* feat: Mobile UI

* feat: add SEARCH route constant

* feat: create SidebarNavigationSearchResults component

* refactor: use SidebarNavigationSearchResults in conversation list

* feat: enable mobile search navigation in sidebar actions

* feat: add mobile search route and page

* fix: prevent sidebar overflow on mobile viewports

* fix: Mobile sidebar

* feat: Mobile Search WIP

* feat: Mobile WIP

* feat: Add PWA standalone detection and refine mobile UI

* feat: Improve mobile layout, sidebar handling, and chat scrolling

* feat: Improve mobile sidebar visibility and iOS Safari chat spacing

* fix: Disable auto-scroll on mobile

* chore: Linting

* fix: Wrong condition

* feat: Mobile chat scroll

* refactor: WIP

* fix: Desktop initial scroll always working again

* fix: Partial fix for mobile auto-scroll / initial scroll

* fix: Desktop auto-scroll on initial load and during streaming

* fix: Mobile scrolling logic

* refactor: Clean up

* feat: Improve start UI

* feat: Add `delay` to `fadeInView`

* feat: Auto-scroll button

* refactor: Cleanup

* refactor: Extract chat dialogs and alerts into dedicated component

* refactor: Reorganize ChatScreen component structure and initialization

* feat: Improve auto-scroll after sending message

* feat: UI improvements

* fix: Settings link

* feat: UI improvements

* fix: better UI spacing

* fix: Remove unneeded logic

* fix: Chat Processing Info UI rendering

* feat: Improve mobile UI

* feat: UI improvement

* fix: Conditional transition delay for Chat Messages based on route from

* fix: Delay mobile sidebar collapse for smoother transitions

* fix: Mobile scroll down button + sidebar pointer events

* fix: Mobile UI

* fix: Auto scrolling

* fix: Implement dynamic height calculations for chat auto-scroll positioning and UI elements

* fix: Retrieve `autofocus` for Chat Form textarea

* fix: Use proper class

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* refactor: extract scroll-to-bottom logic and fix message send flow

* fix: update viewport store usage and remove conflicting autofocus

* feat: add accessibility labels to scroll down button

* fix: correct HTML structure in sidebar empty states

* fix: dynamically toggle processing info visibility

* chore: remove commented exports and fix formatting

* fix

* fix: Mobile Chat Form Add Action Sheet interactions

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Aleksander Grygier
2026-06-24 10:21:33 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 88636e178f
commit ef9c13d4c2
88 changed files with 2121 additions and 2146 deletions
@@ -105,7 +105,10 @@ export class AutoScrollController {
*/
resetScrollState(): void {
this._userScrolledUp = false;
this._autoScrollEnabled = true;
this._autoScrollEnabled = !this._disabled;
if (this._container) {
this._lastScrollTop = this._container.scrollTop;
}
}
/**
@@ -0,0 +1,100 @@
/**
* Active model resolution and capability detection for the ChatScreen.
*
* Picks the model that should be used for the current view
* (router: user-selected or conversation fallback; non-router: first
* available option), and reactively tracks which modalities (vision /
* audio / video) it supports — fetching model props from the server on
* demand if they aren't cached yet.
*/
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
export function useChatScreenActiveModel() {
const isRouter = $derived(isRouterMode());
const conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
const activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let modelPropsVersion = $state(0);
$effect(() => {
if (activeModelId) {
const cached = modelsStore.getModelProps(activeModelId);
if (!cached) {
modelsStore.fetchModelProps(activeModelId).then(() => {
modelPropsVersion++;
});
}
}
});
const hasAudioModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsAudio(activeModelId);
}
return false;
});
const hasVideoModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVideo(activeModelId);
}
return false;
});
const hasVisionModality = $derived.by(() => {
if (activeModelId) {
void modelPropsVersion;
return modelsStore.modelSupportsVision(activeModelId);
}
return false;
});
return {
get isRouter() {
return isRouter;
},
get conversationModel() {
return conversationModel;
},
get activeModelId() {
return activeModelId;
},
get hasAudioModality() {
return hasAudioModality;
},
get hasVideoModality() {
return hasVideoModality;
},
get hasVisionModality() {
return hasVisionModality;
}
};
}
@@ -0,0 +1,72 @@
/**
* Drag-and-drop state machine for the ChatScreen.
*
* Tracks pointer enter/leave nesting so the overlay stays visible while the
* cursor traverses child elements, then routes the dropped files either to
* the active message-edit handler (if a message is being edited) or to the
* caller's onDrop callback.
*/
import { getAddFilesHandler, isEditing } from '$lib/stores/chat.svelte';
interface UseChatScreenDragAndDropOptions {
/** Called when the user drops files and no message is being edited. */
onDrop: (files: File[]) => void;
}
export function useChatScreenDragAndDrop(options: UseChatScreenDragAndDropOptions) {
let dragCounter = $state(0);
let isDragOver = $state(false);
function handleDragEnter(event: DragEvent) {
event.preventDefault();
dragCounter++;
if (event.dataTransfer?.types.includes('Files')) {
isDragOver = true;
}
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
dragCounter--;
if (dragCounter === 0) {
isDragOver = false;
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
if (!event.dataTransfer?.files) return;
const files = Array.from(event.dataTransfer.files);
if (isEditing()) {
const handler = getAddFilesHandler();
if (handler) {
handler(files);
return;
}
}
options.onDrop(files);
}
return {
get isDragOver() {
return isDragOver;
},
dragHandlers: {
dragenter: handleDragEnter,
dragleave: handleDragLeave,
dragover: handleDragOver,
drop: handleDrop
}
};
}
@@ -0,0 +1,104 @@
/**
* File upload lifecycle for the ChatScreen form.
*
* Owns the queue of processed `ChatUploadedFile`, the rejection-by-capability
* dialog state, and the dual-layer validation pipeline (general format +
* model modality). The caller provides the active model's capabilities and ID
* as reactive getters so validation tracks the model in real time.
*/
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
import { isFileTypeSupported, filterFilesByModalities } from '$lib/utils';
interface UseChatScreenFileUploadOptions {
capabilities: () => { hasVision: boolean; hasAudio: boolean; hasVideo: boolean };
activeModelId: () => string | null | undefined;
}
export interface FileErrorData {
generallyUnsupported: File[];
modalityUnsupported: File[];
modalityReasons: Record<string, string>;
supportedTypes: string[];
}
export function useChatScreenFileUpload(options: UseChatScreenFileUploadOptions) {
let uploadedFiles = $state<ChatUploadedFile[]>([]);
let showFileErrorDialog = $state(false);
let fileErrorData = $state<FileErrorData>({
generallyUnsupported: [],
modalityUnsupported: [],
modalityReasons: {},
supportedTypes: []
});
async function processFiles(files: File[]) {
const generallySupported: File[] = [];
const generallyUnsupported: File[] = [];
for (const file of files) {
if (isFileTypeSupported(file.name, file.type)) {
generallySupported.push(file);
} else {
generallyUnsupported.push(file);
}
}
const { supportedFiles, unsupportedFiles, modalityReasons } = filterFilesByModalities(
generallySupported,
options.capabilities()
);
const allUnsupportedFiles = [...generallyUnsupported, ...unsupportedFiles];
if (allUnsupportedFiles.length > 0) {
const supportedTypes: string[] = ['text files', 'PDFs'];
const caps = options.capabilities();
if (caps.hasVision) supportedTypes.push('images');
if (caps.hasAudio) supportedTypes.push('audio files');
if (caps.hasVideo) supportedTypes.push('video files');
fileErrorData = {
generallyUnsupported,
modalityUnsupported: unsupportedFiles,
modalityReasons,
supportedTypes
};
showFileErrorDialog = true;
}
if (supportedFiles.length > 0) {
const processed = await processFilesToChatUploaded(
supportedFiles,
options.activeModelId() ?? undefined
);
uploadedFiles = [...uploadedFiles, ...processed];
}
}
function handleFileUpload(files: File[]) {
return processFiles(files);
}
function handleFileRemove(fileId: string) {
uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
}
return {
get uploadedFiles() {
return uploadedFiles;
},
set uploadedFiles(value) {
uploadedFiles = value;
},
get showFileErrorDialog() {
return showFileErrorDialog;
},
set showFileErrorDialog(value) {
showFileErrorDialog = value;
},
fileErrorData,
handleFileUpload,
handleFileRemove
};
}
@@ -0,0 +1,47 @@
/**
* Scroll container binding and navigation guard for the ChatScreen.
*
* Binds the `AutoScrollController` to `document.documentElement`, exposes
* the container for programmatic scrolling, and flags an `isNavigating`
* window during route changes so the controller can reset without its
* scroll handler seeing spurious events from layout shifts.
*/
import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { AutoScrollController } from './use-auto-scroll.svelte';
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
let isNavigating = $state(false);
function handleScroll(event: UIEvent) {
// Ignore scroll events caused by navigation layout changes or by our own
// programmatic scrolls so they don't accidentally disable auto-scroll.
if (isNavigating || !event.isTrusted) return;
autoScroll.handleScroll();
}
beforeNavigate(() => {
isNavigating = true;
autoScroll.resetScrollState();
});
afterNavigate(() => {
setTimeout(() => {
isNavigating = false;
autoScroll.resetScrollState();
}, 10);
});
$effect(() => {
chatScrollContainer = document.documentElement;
autoScroll.setContainer(chatScrollContainer);
});
return {
get chatScrollContainer() {
return chatScrollContainer;
},
handleScroll
};
}
@@ -143,7 +143,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
textarea?.focus({ preventScroll: true });
});
}