6 Commits

Author SHA1 Message Date
Ryan Chen
07c272c96a Extend JWT token expiration times
Access tokens now last 1 hour (up from default 15 min) and refresh
tokens last 30 days, reducing frequent re-authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 10:10:47 -04:00
ryan
975a337af4 Merge pull request 'Fix mobile performance degradation during typing and after image upload' (#21) from fix/mobile-input-performance into main
Reviewed-on: #21
2026-04-05 06:59:39 -04:00
Ryan Chen
e644def141 Fix mobile performance degradation during typing and after image upload
Memoize blob URL creation to prevent leak on every keystroke, wrap
MessageInput in React.memo with stable useCallback props, remove
expensive backdrop-blur-sm from chat footer, and use instant scroll
during streaming to avoid queuing smooth scroll animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 06:58:53 -04:00
ryan
be600e78d6 Merge pull request 'Fix images not sending in existing conversations' (#19) from fix/image-in-existing-conversations into main
Reviewed-on: #19
2026-04-04 09:08:46 -04:00
Ryan Chen
b6576fb2fd Fix images not sending in existing conversations
Add missing pendingImage, onImageSelect, and onClearImage props to the
MessageInput rendered in the active chat footer, matching the homepage version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:07:21 -04:00
ryan
bb3ef4fe95 Merge pull request 'Fix 401 on image serving with presigned S3 URLs' (#17) from fix/image-presigned-urls into main
Reviewed-on: #17
2026-04-04 08:55:43 -04:00
3 changed files with 42 additions and 17 deletions

3
app.py
View File

@@ -1,5 +1,6 @@
import logging
import os
from datetime import timedelta
from dotenv import load_dotenv
from quart import Quart, jsonify, render_template, request, send_from_directory
@@ -38,6 +39,8 @@ app = Quart(
)
app.config["JWT_SECRET_KEY"] = os.getenv("JWT_SECRET_KEY", "SECRET_KEY")
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024 # 10 MB upload limit
jwt = JWTManager(app)

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef } from "react";
import { useCallback, useEffect, useState, useRef } from "react";
import { LogOut, Shield, PanelLeftClose, PanelLeftOpen, Menu, X } from "lucide-react";
import { conversationService } from "../api/conversationService";
import { userService } from "../api/userService";
@@ -63,9 +63,13 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
const abortControllerRef = useRef<AbortController | null>(null);
const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const scrollToBottom = useCallback(() => {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({
behavior: isLoading ? "instant" : "smooth",
});
});
}, [isLoading]);
useEffect(() => {
isMountedRef.current = true;
@@ -130,7 +134,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
load();
}, [selectedConversation?.id]);
const handleQuestionSubmit = async () => {
const handleQuestionSubmit = useCallback(async () => {
if ((!query.trim() && !pendingImage) || isLoading) return;
let activeConversation = selectedConversation;
@@ -214,19 +218,22 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
if (isMountedRef.current) setIsLoading(false);
abortControllerRef.current = null;
}
};
}, [query, pendingImage, isLoading, selectedConversation, simbaMode, messages, setAuthenticated]);
const handleQueryChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
setQuery(event.target.value);
};
}, []);
const handleKeyDown = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleKeyDown = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
const kev = event as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
if (kev.key === "Enter" && !kev.shiftKey) {
kev.preventDefault();
handleQuestionSubmit();
}
};
}, [handleQuestionSubmit]);
const handleImageSelect = useCallback((file: File) => setPendingImage(file), []);
const handleClearImage = useCallback(() => setPendingImage(null), []);
const handleLogout = () => {
localStorage.removeItem("access_token");
@@ -380,8 +387,8 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
onImageSelect={handleImageSelect}
onClearImage={handleClearImage}
/>
</div>
</div>
@@ -416,7 +423,7 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
</div>
</div>
<footer className="border-t border-sand-light/40 bg-cream/80 backdrop-blur-sm">
<footer className="border-t border-sand-light/40 bg-cream">
<div className="max-w-2xl mx-auto px-4 py-3">
<MessageInput
query={query}
@@ -425,6 +432,9 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => {
handleQuestionSubmit={handleQuestionSubmit}
setSimbaMode={setSimbaMode}
isLoading={isLoading}
pendingImage={pendingImage}
onImageSelect={(file) => setPendingImage(file)}
onClearImage={() => setPendingImage(null)}
/>
</div>
</footer>

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUp, ImagePlus, X } from "lucide-react";
import { cn } from "../lib/utils";
import { Textarea } from "./ui/textarea";
@@ -15,7 +15,7 @@ type MessageInputProps = {
onClearImage: () => void;
};
export const MessageInput = ({
export const MessageInput = React.memo(({
query,
handleKeyDown,
handleQueryChange,
@@ -29,6 +29,18 @@ export const MessageInput = ({
const [simbaMode, setLocalSimbaMode] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Create blob URL once per file, revoke on cleanup
const previewUrl = useMemo(
() => (pendingImage ? URL.createObjectURL(pendingImage) : null),
[pendingImage],
);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const toggleSimbaMode = () => {
const next = !simbaMode;
setLocalSimbaMode(next);
@@ -59,7 +71,7 @@ export const MessageInput = ({
<div className="px-3 pt-3">
<div className="relative inline-block">
<img
src={URL.createObjectURL(pendingImage)}
src={previewUrl!}
alt="Pending upload"
className="h-20 rounded-lg object-cover border border-sand"
/>
@@ -145,4 +157,4 @@ export const MessageInput = ({
</div>
</div>
);
};
});