Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 12 additions & 15 deletions src/gaia/apps/webui/src/components/ChatView.css
Original file line number Diff line number Diff line change
Expand Up @@ -579,21 +579,6 @@
.msg-input::placeholder { color: var(--text-muted); opacity: 0.6; font-family: var(--font-sans); }
.msg-input:disabled { opacity: 0.5; }

/* Blinking block cursor -- pixelated terminal aesthetic */
.input-cursor {
display: inline-block;
width: 8px;
height: 17px;
background: var(--amd-red);
animation: cursorBlink 1s step-end infinite;
align-self: center;
flex-shrink: 0;
border-radius: 0;
image-rendering: pixelated;
box-shadow: 0 0 8px rgba(237, 28, 36, 0.5), 0 0 2px rgba(237, 28, 36, 0.8);
margin-right: 4px;
}

.input-btns {
display: flex;
gap: 2px;
Expand Down Expand Up @@ -742,6 +727,18 @@
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}

/* Block cursor — terminal aesthetic, tracks caret position via inline style */
.input-cursor {
position: absolute;
width: 10px;
height: 18px;
background: var(--amd-red);
animation: cursorBlink 1s step-end infinite;
pointer-events: none;
box-shadow: 0 0 8px rgba(237, 28, 36, 0.5), 0 0 2px rgba(237, 28, 36, 0.8);
}

.input-box.has-attachments {
Expand Down
95 changes: 78 additions & 17 deletions src/gaia/apps/webui/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,45 @@ import { bugReportUrl } from './UnsupportedFeature';
import type { Message, StreamEvent, AgentStep, Attachment } from '../types';
import './ChatView.css';

/** Cache for getComputedStyle results — avoids repeated style recalculations
* for the same textarea element since its styles rarely change. */
const _computedStyleCache = new WeakMap<HTMLTextAreaElement, CSSStyleDeclaration>();

/** Returns the pixel {x, y} of the caret inside a textarea, measured from the
* textarea's top-left corner (including its padding). Uses a hidden mirror div
* with matching styles so the result works for any font and multiline input.
* Accounts for scrollTop so the position stays correct when content overflows. */
function getCaretXY(el: HTMLTextAreaElement): { x: number; y: number } {
const sel = el.selectionStart ?? 0;
let computed = _computedStyleCache.get(el);
if (!computed) {
computed = window.getComputedStyle(el);
_computedStyleCache.set(el, computed);
}
const mirror = document.createElement('div');
mirror.style.cssText = [
'position:absolute', 'visibility:hidden', 'overflow:hidden',
'white-space:pre-wrap', 'word-wrap:break-word',
'top:-9999px', 'left:-9999px',
`box-sizing:${computed.boxSizing}`,
`width:${computed.width}`,
`padding:${computed.padding}`,
`border:${computed.border}`,
`font:${computed.font}`,
`line-height:${computed.lineHeight}`,
`letter-spacing:${computed.letterSpacing}`,
`word-spacing:${computed.wordSpacing}`,
].join(';');
mirror.appendChild(document.createTextNode(el.value.substring(0, sel)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
document.body.appendChild(mirror);
const coords = { x: marker.offsetLeft, y: marker.offsetTop - el.scrollTop };
document.body.removeChild(mirror);
return coords;
}

const EMPTY_SUGGESTIONS = [
'Summarize a document',
'Find a file on my computer',
Expand Down Expand Up @@ -137,6 +176,7 @@ export function ChatView({ sessionId }: ChatViewProps) {
const sessionDocIds = new Set(session?.document_ids ?? []);
const sessionDocs = documents.filter(d => sessionDocIds.has(d.id));
const [input, setInput] = useState('');
const [caret, setCaret] = useState({ x: 0, y: 0, focused: false });
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
Expand Down Expand Up @@ -173,6 +213,16 @@ export function ChatView({ sessionId }: ChatViewProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const caretRafRef = useRef<number>(0);
const updateCaret = useCallback(() => {
cancelAnimationFrame(caretRafRef.current);
caretRafRef.current = requestAnimationFrame(() => {
if (!inputRef.current) return;
const { x, y } = getCaretXY(inputRef.current);
setCaret(prev => ({ ...prev, x, y }));
});
}, []);
useEffect(() => () => cancelAnimationFrame(caretRafRef.current), []);
const abortRef = useRef<AbortController | null>(null);
const stepIdRef = useRef(0);
const toolOccurredRef = useRef(false);
Expand All @@ -184,7 +234,7 @@ export function ChatView({ sessionId }: ChatViewProps) {
// (which can be hundreds/sec), dramatically reducing DOM mutations
// and eliminating extension-triggered "runtime.lastError" floods.
const streamBufferRef = useRef('');
const rafRef = useRef<number | null>(null);
const streamRafRef = useRef<number | null>(null);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Timestamp of the last auto-scroll (used for throttling). */
const lastScrollRef = useRef(0);
Expand All @@ -194,7 +244,7 @@ export function ChatView({ sessionId }: ChatViewProps) {
const isNearBottomRef = useRef(true);

const flushStreamBuffer = useCallback(() => {
rafRef.current = null;
streamRafRef.current = null;
if (streamBufferRef.current) {
setStreamContent(streamBufferRef.current);
}
Expand Down Expand Up @@ -309,9 +359,9 @@ export function ChatView({ sessionId }: ChatViewProps) {
abortRef.current.abort();
abortRef.current = null;
}
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current);
streamRafRef.current = null;
}
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current);
Expand Down Expand Up @@ -349,9 +399,9 @@ export function ChatView({ sessionId }: ChatViewProps) {
abortRef.current = null;
}
// Cancel any pending rAF flush
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current);
streamRafRef.current = null;
}
// Use the buffer (most up-to-date) or fall back to store content
const storeState = useChatStore.getState();
Expand Down Expand Up @@ -399,6 +449,7 @@ export function ChatView({ sessionId }: ChatViewProps) {
const el = e.target;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
updateCaret();
};

// Handle clipboard paste (screenshots)
Expand Down Expand Up @@ -611,8 +662,8 @@ export function ChatView({ sessionId }: ChatViewProps) {
// Buffer chunks and flush to store at most once per frame (~60fps)
// instead of triggering a React re-render on every single SSE chunk
streamBufferRef.current = cleaned;
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(flushStreamBuffer);
if (streamRafRef.current === null) {
streamRafRef.current = requestAnimationFrame(flushStreamBuffer);
}
}
},
Expand Down Expand Up @@ -813,9 +864,9 @@ export function ChatView({ sessionId }: ChatViewProps) {
doneHandled = true;

// Cancel any pending rAF flush — we have the final content
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current);
streamRafRef.current = null;
}
streamBufferRef.current = '';

Expand Down Expand Up @@ -880,9 +931,9 @@ export function ChatView({ sessionId }: ChatViewProps) {
},
onError: (err) => {
// Cancel any pending rAF flush
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (streamRafRef.current !== null) {
cancelAnimationFrame(streamRafRef.current);
streamRafRef.current = null;
}
streamBufferRef.current = '';

Expand Down Expand Up @@ -1398,13 +1449,23 @@ export function ChatView({ sessionId }: ChatViewProps) {
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onSelect={updateCaret}
onFocus={() => { setCaret(prev => ({ ...prev, focused: true })); updateCaret(); }}
onBlur={() => setCaret(prev => ({ ...prev, focused: false }))}
placeholder="Type a message or paste an image... (Shift+Enter for new line)"
rows={1}
disabled={isStreaming}
aria-label="Message input"
style={{ caretColor: 'transparent' }}
/>
{!isStreaming && caret.focused && (
<span
className="input-cursor"
style={{ left: `${caret.x}px`, top: `${caret.y}px` }}
aria-hidden="true"
/>
)}
</div>
{!isStreaming && <span className="input-cursor" aria-hidden="true" />}
<div className="input-btns">
<button className="btn-icon-sm" onClick={() => setShowDocLibrary(true)} title="Upload document" aria-label="Upload document">
<Upload size={15} />
Expand Down
15 changes: 11 additions & 4 deletions tests/electron/test_electron_chat_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,16 @@ describe('Chat App Integration', () => {
});

it('should have React as dependency', () => {
expect(pkg.dependencies.react).toBeDefined();
expect(pkg.dependencies['react-dom']).toBeDefined();
expect(pkg.devDependencies.react).toBeDefined();
expect(pkg.devDependencies['react-dom']).toBeDefined();
});

it('should have Zustand for state management', () => {
expect(pkg.dependencies.zustand).toBeDefined();
expect(pkg.devDependencies.zustand).toBeDefined();
});

it('should have lucide-react for icons', () => {
expect(pkg.dependencies['lucide-react']).toBeDefined();
expect(pkg.devDependencies['lucide-react']).toBeDefined();
});

it('should have TypeScript as devDependency', () => {
Expand Down Expand Up @@ -1116,6 +1116,13 @@ describe('Chat App Integration', () => {
it('should have chat title overflow handling', () => {
expect(chatCss).toContain('text-overflow: ellipsis');
});

it('should have terminal block cursor tracking caret position', () => {
expect(chatCss).toContain('.input-cursor');
expect(chatCss).toContain('position: absolute');
expect(chatCss).toContain('pointer-events: none');
expect(chatCss).toContain('width: 10px');
});
});

// ── MessageBubble Enhancements ────────────────────────────────────
Expand Down
Loading