Merge pull request #1380 from jmd1010/web-windows-resizing

Add flex windows sizing to web interface + raw text input fix
This commit is contained in:
Eugen Eisler
2025-03-22 09:51:07 +01:00
committed by GitHub
4 changed files with 308 additions and 50 deletions

View File

@@ -26,7 +26,17 @@
let rightColumnWidth = 50;
let isDragging = false;
// Handle resize functionality
// Message input height state (percentage values)
const DEFAULT_INPUT_HEIGHT = 30; // Default percentage of the left column
const MAX_INPUT_HEIGHT = DEFAULT_INPUT_HEIGHT * 2; // Maximum 200% of default height
const MIN_SYSTEM_INSTRUCTIONS_HEIGHT = 20; // Minimum percentage for system instructions
let messageInputHeight = DEFAULT_INPUT_HEIGHT;
let systemInstructionsHeight = 100 - DEFAULT_INPUT_HEIGHT;
let isVerticalDragging = false;
let initialMouseY = 0; // Track initial mouse position
let initialInputHeight = 0; // Track initial input height
// Handle horizontal resize functionality
function startResize(e: MouseEvent | KeyboardEvent) {
isDragging = true;
e.preventDefault();
@@ -57,11 +67,76 @@
// Calculate percentage based on mouse position
const percentage = ((e.clientX - containerRect.left) / containerWidth) * 100;
// Apply constraints (left: 25-50%, right: 50-75%)
leftColumnWidth = Math.min(Math.max(percentage, 25), 50);
// Apply constraints (left: 40-80%, right: 20-60%)
leftColumnWidth = Math.min(Math.max(percentage, 40), 80);
rightColumnWidth = 100 - leftColumnWidth;
}
// Handle vertical resize functionality
function startVerticalResize(e: MouseEvent | KeyboardEvent) {
isVerticalDragging = true;
e.preventDefault();
// Store initial mouse position and input height
if (e instanceof MouseEvent) {
initialMouseY = e.clientY;
initialInputHeight = messageInputHeight;
}
// Add event listeners for drag and release
window.addEventListener('mousemove', handleVerticalResize);
window.addEventListener('mouseup', stopVerticalResize);
}
function handleVerticalKeyDown(e: KeyboardEvent) {
// Only respond to Enter or Space key
if (e.key === 'Enter' || e.key === ' ') {
startVerticalResize(e);
}
}
function handleVerticalResize(e: MouseEvent) {
if (!isVerticalDragging) return;
// Get container dimensions
const leftColumn = document.querySelector('.left-column');
if (!leftColumn) return;
// Get system instructions element to check its actual height
const sysInstructions = leftColumn.querySelector('.system-instructions');
if (!sysInstructions) return;
const columnRect = leftColumn.getBoundingClientRect();
const columnHeight = columnRect.height;
// Calculate height change based on mouse movement
const mouseDelta = e.clientY - initialMouseY;
const deltaPercentage = (mouseDelta / columnHeight) * 100;
const newHeight = initialInputHeight + deltaPercentage;
// Apply constraints to ensure system instructions remain visible
const minHeight = DEFAULT_INPUT_HEIGHT * 0.25; // 25% of default
const maxHeight = Math.min(MAX_INPUT_HEIGHT, 100 - MIN_SYSTEM_INSTRUCTIONS_HEIGHT); // Max 200% of default or ensure system instructions are visible
// Calculate new heights
const constrainedHeight = Math.min(Math.max(newHeight, minHeight), maxHeight);
const newSysInstructionsHeight = 100 - constrainedHeight;
// Additional safety check - don't allow resize if it would make system instructions too small
const sysInstructionsPixelHeight = (columnHeight * newSysInstructionsHeight) / 100;
if (sysInstructionsPixelHeight < 100) return; // Don't resize if it would be less than 100px
// Apply the new heights
messageInputHeight = constrainedHeight;
systemInstructionsHeight = newSysInstructionsHeight;
}
function stopVerticalResize() {
isVerticalDragging = false;
window.removeEventListener('mousemove', handleVerticalResize);
window.removeEventListener('mouseup', stopVerticalResize);
}
function stopResize() {
isDragging = false;
window.removeEventListener('mousemove', handleResize);
@@ -73,6 +148,8 @@
return () => {
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('mousemove', handleVerticalResize);
window.removeEventListener('mouseup', stopVerticalResize);
};
});
@@ -81,26 +158,30 @@
<div class="chat-container flex gap-0 p-2 w-full h-screen">
<!-- Left Column -->
<aside class="flex flex-col gap-2 pr-2" style="width: {leftColumnWidth}%">
<!-- Dropdowns Group -->
<aside class="flex flex-col gap-2 pr-2 left-column" style="width: {leftColumnWidth}%">
<!-- Dropdowns Group with Model Config -->
<div class="bg-background/5 p-2 rounded-lg">
<div class="rounded-lg bg-background/10">
<DropdownGroup />
</div>
</div>
<!-- Model Config -->
<div class="bg-background/5 p-2 rounded-lg">
<ModelConfig />
</div>
<!-- Message Input -->
<div class="h-[200px] bg-background/5 rounded-lg overflow-hidden">
<div class="bg-background/5 rounded-lg overflow-hidden" style="height: {messageInputHeight}%; max-height: {MAX_INPUT_HEIGHT}%">
<ChatInput />
</div>
<!-- Vertical Resize Handle -->
<button
class="vertical-resize-handle"
on:mousedown={startVerticalResize}
on:keydown={handleVerticalKeyDown}
type="button"
aria-label="Resize message input and system instructions"
></button>
<!-- System Instructions -->
<div class="flex-1 min-h-0 bg-background/5 p-2 rounded-lg">
<div class="flex-1 min-h-[100px] bg-background/5 p-2 rounded-lg system-instructions">
<div class="h-full flex flex-col">
<Textarea
bind:value={$systemPrompt}
@@ -168,6 +249,7 @@
<NoteDrawer />
<style>
/* Horizontal resize handle */
.resize-handle {
width: 6px;
margin: 0 -3px;
@@ -205,6 +287,44 @@
width: 4px;
}
/* Vertical resize handle */
.vertical-resize-handle {
height: 6px;
margin: -3px 0;
width: 100%;
cursor: row-resize;
position: relative;
z-index: 10;
transition: background-color 0.2s;
}
.vertical-resize-handle::after {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 100%;
height: 2px;
background-color: rgba(255, 255, 255, 0.1);
transition: background-color 0.2s, height 0.2s;
}
.vertical-resize-handle:hover::after,
.vertical-resize-handle:focus::after {
background-color: rgba(255, 255, 255, 0.3);
height: 4px;
}
.vertical-resize-handle:focus {
outline: none;
}
.vertical-resize-handle:focus-visible::after {
background-color: rgba(255, 255, 255, 0.5);
height: 4px;
}
@keyframes flash {
0% { opacity: 1; }
50% { opacity: 0.5; }

View File

@@ -377,10 +377,137 @@ async function readFileContent(file: File): Promise<string> {
}
}
async function handleSubmit() {
if (!userInput.trim()) return;
try {
console.log('\n=== Submit Handler Start ===');
// Store the user input before any processing
const inputText = userInput.trim();
console.log('Captured user input:', inputText);
// Handle YouTube URLs with the existing flow
if (isYouTubeURL) {
console.log('2a. Starting YouTube flow');
await processYouTubeURL(inputText);
return;
}
// For regular text input, add the user message to the UI first
messageStore.update(messages => [...messages, {
role: 'user',
content: inputText
}]);
// Add loading indicator
messageStore.update(messages => [...messages, {
role: 'system',
content: 'Processing...',
format: 'loading'
}]);
// Clear input fields
userInput = "";
const filesForProcessing = [...uploadedFiles];
const contentsForProcessing = [...fileContents];
uploadedFiles = [];
fileContents = [];
fileButtonKey = !fileButtonKey;
// Prepare content with file attachments if any
const contentWithFiles = contentsForProcessing.length > 0
? `${inputText}\n\nFile Contents (${filesForProcessing.map(f => f.endsWith('.pdf') ? 'PDF' : 'Text').join(', ')}):\n${contentsForProcessing.join('\n\n---\n\n')}`
: inputText;
// Get the enhanced prompt
const enhancedPrompt = contentsForProcessing.length > 0
? `${$systemPrompt}\nAnalyze and process the provided content according to these instructions.`
: $systemPrompt;
console.log('Content to send:', {
text: contentWithFiles.substring(0, 100) + '...',
length: contentWithFiles.length,
hasFiles: contentsForProcessing.length > 0
});
try {
// Get the chat stream
const stream = await chatService.streamChat(contentWithFiles, enhancedPrompt);
// Process the stream
await chatService.processStream(
stream,
(content, response) => {
messageStore.update(messages => {
const newMessages = [...messages];
// Remove the loading message
const loadingIndex = newMessages.findIndex(m => m.format === 'loading');
if (loadingIndex !== -1) {
newMessages.splice(loadingIndex, 1);
}
// Add or update the assistant message
const assistantIndex = newMessages.findIndex(m => m.role === 'assistant');
if (assistantIndex !== -1) {
newMessages[assistantIndex].content = content;
newMessages[assistantIndex].format = response?.format;
} else {
newMessages.push({
role: 'assistant',
content,
format: response?.format
});
}
return newMessages;
});
},
(error) => {
// Make sure to remove loading message on error
messageStore.update(messages =>
messages.filter(m => m.format !== 'loading')
);
console.error('Stream processing error:', error);
// Show error message using a valid format type
messageStore.update(messages => [...messages, {
role: 'system',
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
format: 'plain'
}]);
}
);
} catch (error) {
// Make sure to remove loading message on error
messageStore.update(messages =>
messages.filter(m => m.format !== 'loading')
);
throw error; // Re-throw to be caught by the outer try/catch
}
} catch (error) {
console.error('Chat submission error:', error);
// Make sure to remove loading message on error (redundant but safe)
messageStore.update(messages =>
messages.filter(m => m.format !== 'loading')
);
// Show error message using a valid format type
messageStore.update(messages => [...messages, {
role: 'system',
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
format: 'plain'
}]);
} finally {
// As a final safety measure, ensure loading message is removed
messageStore.update(messages =>
messages.filter(m => m.format !== 'loading')
);
}
}
async function handleSubmit() {
/* async function handleSubmit() {
if (!userInput.trim()) return;
try {
@@ -403,29 +530,28 @@ async function handleSubmit() {
format: 'loading'
}]);
userInput = ""; // Reset userInput BEFORE sendMessage
uploadedFiles = []; // Reset uploadedFiles BEFORE sendMessage
fileContents = []; // Reset fileContents BEFORE sendMessage
fileButtonKey = !fileButtonKey; // Toggle key to force re-creation
// Store the user input before clearing it
const inputText = userInput;
// Construct finalContent BEFORE clearing userInput
const finalContent = fileContents.length > 0
? `${userInput}\n\nFile Contents (${uploadedFiles.map(f => f.endsWith('.pdf') ? 'PDF' : 'Text').join(', ')}):\n${fileContents.join('\n\n---\n\n')}`
: userInput;
? `${inputText}\n\nFile Contents (${uploadedFiles.map(f => f.endsWith('.pdf') ? 'PDF' : 'Text').join(', ')}):\n${fileContents.join('\n\n---\n\n')}`
: inputText;
// Now clear the input fields
userInput = "";
uploadedFiles = [];
fileContents = [];
fileButtonKey = !fileButtonKey;
await sendMessage(finalContent, enhancedPrompt);
} catch (error) {
console.error('Chat submission error:', error);
}
}
} */
function handleKeydown(event: KeyboardEvent) {

View File

@@ -35,10 +35,13 @@
$: if ($chatState.messages.length > 0) {
const lastMessage = $chatState.messages[$chatState.messages.length - 1];
isUserMessage = lastMessage.role === 'user';
if (isUserMessage) {
// Only auto-scroll on user messages
setTimeout(scrollToBottom, 100);
}
// Auto-scroll on both user messages and assistant messages
setTimeout(scrollToBottom, 100);
}
// Also watch for streaming state changes to ensure scrolling when streaming completes
$: if ($streamingStore === false) {
setTimeout(scrollToBottom, 100);
}
onMount(() => {

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import Patterns from "./Patterns.svelte";
import Models from "./Models.svelte";
import ModelConfig from "./ModelConfig.svelte";
import { Select } from "$lib/components/ui/select";
import { languageStore } from '$lib/store/language-store';
@@ -15,21 +16,29 @@
];
</script>
<div class="flex flex-col gap-3">
<div class="w-[50%]">
<Patterns />
<div class="flex gap-4">
<!-- Left side - Dropdowns -->
<div class="w-[35%] flex flex-col gap-3">
<div>
<Patterns />
</div>
<div>
<Models />
</div>
<div>
<Select
bind:value={$languageStore}
class="bg-primary-800/30 border-none hover:bg-primary-800/40 transition-colors"
>
{#each languages as lang}
<option value={lang.code}>{lang.name}</option>
{/each}
</Select>
</div>
</div>
<div class="w-[50%]">
<Models />
</div>
<div class="w-[50%]">
<Select
bind:value={$languageStore}
class="bg-primary-800/30 border-none hover:bg-primary-800/40 transition-colors"
>
{#each languages as lang}
<option value={lang.code}>{lang.name}</option>
{/each}
</Select>
<!-- Right side - Model Config -->
<div class="w-[65%]">
<ModelConfig />
</div>
</div>