diff --git a/.living_spec/specs/functional/UI_UX.md b/.living_spec/specs/functional/UI_UX.md index 79390e7..6171a14 100644 --- a/.living_spec/specs/functional/UI_UX.md +++ b/.living_spec/specs/functional/UI_UX.md @@ -36,7 +36,75 @@ The system now implements full token streaming for real-time response display: ### 3. Visuals * **Loading State:** The "Send" button should show a spinner or "Stop" button. -* **Auto-Scroll:** The chat view should stick to the bottom as new events arrive. +* **Auto-Scroll:** The chat view uses smart auto-scroll that respects user scrolling (see Smart Auto-Scroll section below). + +## Smart Auto-Scroll (Story 22) + +### Problem +Users need to review previous messages while the AI is streaming new content, but aggressive auto-scrolling constantly drags them back to the bottom, making it impossible to read older content. + +### Solution: Scroll-Position-Aware Auto-Scroll + +The chat implements intelligent auto-scroll that: +* Automatically scrolls to show new content when the user is at/near the bottom +* Pauses auto-scroll when the user scrolls up to review older messages +* Resumes auto-scroll when the user scrolls back to the bottom + +### Requirements + +1. **Scroll Detection:** Track whether the user is at the bottom of the chat +2. **Threshold:** Define "near bottom" as within 25px of the bottom +3. **Auto-Scroll Logic:** Only trigger auto-scroll if user is at/near bottom +4. **Smooth Operation:** No flickering or jarring behavior during scrolling +5. **Universal:** Works during both streaming responses and tool execution + +### Implementation Notes + +**Core Components:** +* `scrollContainerRef`: Reference to the scrollable messages container +* `shouldAutoScrollRef`: Tracks whether auto-scroll should be active (uses ref to avoid re-renders) +* `messagesEndRef`: Target element for scroll-to-bottom behavior + +**Detection Function:** +```typescript +const isScrolledToBottom = () => { + const element = scrollContainerRef.current; + if (!element) return true; + const threshold = 25; // pixels from bottom + return ( + element.scrollHeight - element.scrollTop - element.clientHeight < threshold + ); +}; +``` + +**Scroll Handler:** +```typescript +const handleScroll = () => { + // Update auto-scroll state based on scroll position + shouldAutoScrollRef.current = isScrolledToBottom(); +}; +``` + +**Conditional Auto-Scroll:** +```typescript +useEffect(() => { + if (shouldAutoScrollRef.current) { + scrollToBottom(); + } +}, [messages, streamingContent]); +``` + +**DOM Setup:** +* Attach `ref={scrollContainerRef}` to the messages container +* Attach `onScroll={handleScroll}` to detect user scrolling +* Initialize `shouldAutoScrollRef` to `true` (enable auto-scroll by default) + +### Edge Cases + +1. **Initial Load:** Auto-scroll is enabled by default +2. **Rapid Scrolling:** Uses refs to avoid race conditions and excessive re-renders +3. **Manual Scroll to Bottom:** Auto-scroll re-enables when user scrolls near bottom +4. **No Container:** Falls back to always allowing auto-scroll if container ref is null ## Tool Output Display diff --git a/.living_spec/stories/22_smart_autoscroll.md b/.living_spec/stories/22_smart_autoscroll.md index 63e9584..4a144a0 100644 --- a/.living_spec/stories/22_smart_autoscroll.md +++ b/.living_spec/stories/22_smart_autoscroll.md @@ -4,13 +4,13 @@ As a user, I want to be able to scroll up to review previous messages while the AI is streaming or adding new content, without being constantly dragged back to the bottom. ## Acceptance Criteria -- [ ] When I scroll up in the chat, auto-scroll is temporarily disabled -- [ ] Auto-scroll resumes when I scroll back to (or near) the bottom +- [x] When I scroll up in the chat, auto-scroll is temporarily disabled +- [x] Auto-scroll resumes when I scroll back to (or near) the bottom - [ ] There's a visual indicator when auto-scroll is paused (optional) - [ ] Clicking a "Jump to Bottom" button (if added) re-enables auto-scroll -- [ ] Auto-scroll works normally when I'm already at the bottom -- [ ] The detection works smoothly without flickering -- [ ] Works during both streaming responses and tool execution +- [x] Auto-scroll works normally when I'm already at the bottom +- [x] The detection works smoothly without flickering +- [x] Works during both streaming responses and tool execution ## Out of Scope - Manual scroll position restoration after page refresh diff --git a/.living_spec/stories/23_alphabetize_llm_dropdown.md b/.living_spec/stories/23_alphabetize_llm_dropdown.md new file mode 100644 index 0000000..d467f2f --- /dev/null +++ b/.living_spec/stories/23_alphabetize_llm_dropdown.md @@ -0,0 +1,36 @@ +# Story 23: Alphabetize LLM Dropdown List + +## User Story +As a user, I want the LLM model dropdown to be alphabetically sorted so I can quickly find the model I'm looking for. + +## Acceptance Criteria +- [ ] The model dropdown list is sorted alphabetically (case-insensitive) +- [ ] The currently selected model remains selected after sorting +- [ ] The sorting works for all models returned from Ollama +- [ ] The sorted list updates correctly when models are added/removed + +## Out of Scope +- Grouping models by type or provider +- Custom sort orders (e.g., by popularity, recency) +- Search/filter functionality in the dropdown +- Favoriting or pinning specific models to the top + +## Technical Notes +- Models are fetched from `get_ollama_models` Tauri command +- Currently displayed in the order returned by the backend +- Sort should be case-insensitive (e.g., "Llama" and "llama" treated equally) +- JavaScript's `sort()` with `localeCompare()` is ideal for this + +## Implementation Approach +```tsx +// After fetching models from backend +const sortedModels = models.sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()) +); +setAvailableModels(sortedModels); +``` + +## Design Considerations +- Keep it simple - alphabetical order is intuitive +- Case-insensitive to handle inconsistent model naming +- No need to change backend - sorting on frontend is sufficient \ No newline at end of file diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index d586f4c..f1f753a 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -22,6 +22,10 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [streamingContent, setStreamingContent] = useState(""); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const scrollContainerRef = useRef(null); + const shouldAutoScrollRef = useRef(true); + const lastScrollTopRef = useRef(0); + const userScrolledUpRef = useRef(false); // Token estimation and context window tracking const estimateTokens = (text: string): number => { @@ -118,11 +122,43 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { }, []); const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + const element = scrollContainerRef.current; + if (element) { + element.scrollTop = element.scrollHeight; + lastScrollTopRef.current = element.scrollHeight; + } }; + const handleScroll = () => { + const element = scrollContainerRef.current; + if (!element) return; + + const currentScrollTop = element.scrollTop; + const isAtBottom = + element.scrollHeight - element.scrollTop - element.clientHeight < 5; + + // Detect if user scrolled UP + if (currentScrollTop < lastScrollTopRef.current) { + userScrolledUpRef.current = true; + shouldAutoScrollRef.current = false; + } + + // If user scrolled back to bottom, re-enable auto-scroll + if (isAtBottom) { + userScrolledUpRef.current = false; + shouldAutoScrollRef.current = true; + } + + lastScrollTopRef.current = currentScrollTop; + }; + + // Smart auto-scroll: only scroll if user hasn't scrolled up // biome-ignore lint/correctness/useExhaustiveDependencies: We intentionally trigger on messages/streamingContent changes - useEffect(scrollToBottom, [messages, streamingContent]); + useEffect(() => { + if (shouldAutoScrollRef.current && !userScrolledUpRef.current) { + scrollToBottom(); + } + }, [messages, streamingContent]); useEffect(() => { inputRef.current?.focus(); @@ -419,6 +455,8 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { {/* Messages Area */}