Skip to content
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ bun run sandbox:stop # docker compose down -v (destructive: wipes volume)
bun run test # Vitest run (frontend tests only)
bun run test:watch # Vitest watch mode
bun run test:coverage # Vitest with coverage report
bun run test:backend # Cargo test (Rust backend tests)
bun run test:all # Both Vitest and Cargo test
bun run test:backend # Cargo test (Rust backend tests)
bun run test:backend:coverage # Cargo test + llvm-cov, enforces 100% line coverage (mirrors CI)
bun run test:all # Both Vitest and Cargo test

bun run validate-build # All gates: lint + format + typecheck + build
```
Expand All @@ -42,7 +43,7 @@ Tests use **Vitest** for the frontend (React/TypeScript with React Testing Libra
**100% code coverage is mandatory.** Any new or modified code — frontend or backend — must maintain 100% coverage across lines, functions, branches, and statements. PRs that drop below 100% coverage will not be merged.

- **Frontend:** Run `bun run test:coverage` and verify all metrics are 100%.
- **Backend:** Run `cargo +nightly-2026-03-30 llvm-cov --ignore-filename-regex "(lib|main)\.rs" --fail-under-lines 100` from `src-tauri/` to enforce 100% line coverage. Functions excluded from coverage with `#[cfg_attr(coverage_nightly, coverage(off))]` must be thin wrappers (Tauri commands, filesystem I/O) whose logic is tested through the functions they delegate to.
- **Backend:** Run `bun run test:backend:coverage` to enforce 100% line coverage (identical to what CI runs). Functions excluded from coverage with `#[cfg_attr(coverage_nightly, coverage(off))]` must be thin wrappers (Tauri commands, filesystem I/O) whose logic is tested through the functions they delegate to.

## Architecture

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:backend": "cd src-tauri && cargo test",
"test:backend:coverage": "cd src-tauri && cargo +nightly-2026-03-30 llvm-cov --ignore-filename-regex \"(lib|main)\\.rs\" --fail-under-lines 100",
"test:all": "vitest run && cd src-tauri && cargo test",
"validate-build": "bun run lint && bun run format:check && bun run typecheck && bun run build:all"
},
Expand Down
63 changes: 63 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,53 @@ function App() {
*/
const isPreExpandedRef = useRef(false);

/**
* Ref attached to the outermost layout div. Used to set an explicit
* `minHeight` before calling `set_window_frame` in the anchor path so the
* CSS layout matches the new window dimensions before WKWebView's viewport
* size event arrives — preventing the one-frame flash where `h-screen` is
* still the old small height but the window has already repositioned upward.
*/
const outerContainerRef = useRef<HTMLDivElement | null>(null);

/**
* When the LLM starts generating and the window has an upward anchor, expand
* immediately to max height before any streaming tokens arrive.
*
* Streamdown opens empty block elements (`<p></p>`) before their content,
* causing the morphing container to grow in sudden steps. Each step triggers
* a ResizeObserver → set_window_frame cycle that repositions the window
* upward — visible as a jittery jump during upward-anchor sessions.
*
* Expanding to max height in a single `useEffect` call (before the first
* token paint) gives the streaming text a fixed canvas to fill, eliminating
* all incremental upward repositioning during the response.
*/
useEffect(() => {
if (!isGenerating || !windowAnchorRef.current || isPreExpandedRef.current)
return;
const anchor = windowAnchorRef.current;
const maxHeight = Math.min(
MAX_CHAT_WINDOW_HEIGHT,
anchor.bottom_y - anchor.min_y,
);
const newY = anchor.bottom_y - maxHeight;
isPreExpandedRef.current = true;
// Pre-set CSS min-height so justify-end positions correctly during the
// WKWebView viewport update lag that follows set_window_frame.
/* v8 ignore start -- DOM ref null guard: always set when overlay is visible */
if (outerContainerRef.current) {
outerContainerRef.current.style.minHeight = `${maxHeight}px`;
}
/* v8 ignore stop */
void invoke('set_window_frame', {
x: anchor.x,
y: newY,
width: OVERLAY_WIDTH,
height: maxHeight,
});
}, [isGenerating]);

/**
* Callback ref to reliably attach the ResizeObserver when the conditionally
* rendered Framer Motion container actually mounts in the DOM. This fixes
Expand Down Expand Up @@ -206,6 +253,11 @@ function App() {
isPreExpandedRef.current = true;
}

// Pre-set CSS min-height before the native resize so the
// WKWebView layout is correct during its viewport update lag.
if (outerContainerRef.current) {
outerContainerRef.current.style.minHeight = `${neededHeight}px`;
}
// Grow upward incrementally: pin the window bottom to the
// anchor and expand the top edge as content grows. Because
// `set_window_frame` applies position + size atomically on
Expand Down Expand Up @@ -241,6 +293,11 @@ function App() {
(context: string | null, anchor: WindowAnchor | null) => {
windowAnchorRef.current = anchor;
isPreExpandedRef.current = false;
/* v8 ignore start -- DOM ref null guard: always set when overlay is visible */
if (outerContainerRef.current) {
outerContainerRef.current.style.minHeight = '';
}
/* v8 ignore stop */
setIsAnchoredUpward(anchor !== null);
setSessionId((id) => id + 1);
setQuery('');
Expand All @@ -260,6 +317,11 @@ function App() {
const requestHideOverlay = useCallback(() => {
windowAnchorRef.current = null;
isPreExpandedRef.current = false;
/* v8 ignore start -- DOM ref null guard: always set when overlay is visible */
if (outerContainerRef.current) {
outerContainerRef.current.style.minHeight = '';
}
/* v8 ignore stop */
setSelectedContext(null);
setOverlayState((currentState) => {
if (currentState === 'hidden' || currentState === 'hiding') {
Expand Down Expand Up @@ -575,6 +637,7 @@ function App() {
// Minimal padding (pt-2 pb-6) provides just enough physical clearance for the
// tightened drop shadow to render without clipping at the native window edge.
<div
ref={outerContainerRef}
onMouseDown={handleDragStart}
className={`flex flex-col items-center ${isAnchoredUpward ? 'justify-end' : 'justify-start'} h-screen w-screen px-3 pt-2 pb-6 bg-transparent overflow-visible`}
>
Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,59 @@ describe('App', () => {
});
});

it('immediately expands to max height when isGenerating becomes true with upward anchor', async () => {
spyOnResizeObserver();

render(<App />);
await act(async () => {});

// Show with anchor
await act(async () => {
emitTauriEvent('thuki://visibility', {
state: 'show',
selected_text: null,
window_anchor: { x: 100, bottom_y: 884, min_y: 40 },
});
});

// Small initial resize (ask bar only, isGenerating=false)
const container = document.querySelector('.morphing-container');
expect(container).not.toBeNull();
act(() => {
triggerResize(container!, 60);
});

// Submit a message — causes isGenerating to become true
invoke.mockClear();
const textarea = screen.getByPlaceholderText('Ask Thuki anything...');
act(() => {
fireEvent.change(textarea, { target: { value: 'hello' } });
});
act(() => {
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false });
});
await act(async () => {});

// Must immediately call set_window_frame with max height
// max = min(648, 884 - 40 = 844) = 648; newY = 884 - 648 = 236
expect(invoke).toHaveBeenCalledWith('set_window_frame', {
x: 100,
y: 236,
width: 600,
height: 648,
});

// Subsequent resize events must be no-ops (isPreExpandedRef is now true)
invoke.mockClear();
act(() => {
triggerResize(container!, 100);
});
expect(invoke).not.toHaveBeenCalledWith(
'set_window_frame',
expect.anything(),
);
});

it('locks at max height and skips further resize events', async () => {
spyOnResizeObserver();

Expand Down
73 changes: 35 additions & 38 deletions src/components/ChatBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,15 @@ const bubbleVariants = {
};

/**
* Renders an iMessage-inspired chat bubble with role-based styling.
* Renders a chat message following industry-standard assistant UI conventions:
*
* User messages appear right-aligned with a warm gradient (#ff8d5c → #e67a3e).
* AI messages appear left-aligned with a frosted glass surface that harmonizes
* with the dark overlay aesthetic.
* - **User messages** — right-aligned bubble with warm gradient, quoted-text
* support, and an always-visible copy button below the bubble (right-aligned).
* - **AI messages** — full-width plain text (no bubble), markdown-rendered, with
* an always-visible copy button below the text (left-aligned).
*
* A fixed 24px action bar below the bubble always reserves space for the copy
* button, which fades in on hover — no layout shift.
*
* @param props Chat bubble properties including role, content, and stagger index.
* Spring entrance animation is staggered by `index` to produce natural
* choreography when multiple messages appear at once.
*/
export function ChatBubble({
role,
Expand All @@ -65,38 +64,36 @@ export function ChatBubble({
transition={{ delay: index * 0.06 }}
className={`flex w-full ${isUser ? 'justify-end' : 'justify-start'}`}
>
{/* group wrapper: owns max-width, stacks bubble + action bar, enables hover */}
<div className="group flex flex-col max-w-[80%]">
<div
className={`chat-bubble relative px-4 py-2.5 text-sm leading-relaxed select-text ${
isUser
? 'chat-bubble-user rounded-2xl rounded-br-md'
: 'chat-bubble-ai rounded-2xl rounded-bl-md'
}`}
>
{isUser ? (
<>
{quotedText && (
<p className="border-l-2 border-white/40 pl-2 mb-2 italic text-xs text-white/60 whitespace-pre-wrap">
{formatQuotedText(
quotedText,
quote.maxDisplayLines,
quote.maxDisplayChars,
)}
</p>
)}
<span className="text-white/95 font-medium">{content}</span>
</>
) : (
<MarkdownRenderer content={content} isStreaming={isStreaming} />
)}
{isUser ? (
/* User bubble — max-width capped, stacks bubble + action bar */
<div className="flex flex-col max-w-[80%]">
<div className="chat-bubble chat-bubble-user relative px-4 py-2.5 text-sm leading-relaxed select-text rounded-2xl rounded-br-md">
{quotedText && (
<p className="border-l-2 border-white/40 pl-2 mb-2 italic text-xs text-white/60 whitespace-pre-wrap">
{formatQuotedText(
quotedText,
quote.maxDisplayLines,
quote.maxDisplayChars,
)}
</p>
)}
<span className="text-white/95 font-medium">{content}</span>
</div>
<div className="h-6 flex items-center px-1">
<CopyButton content={content} align="right" />
</div>
</div>

{/* Action bar — always 24px tall so layout never shifts on hover */}
<div className="h-6 flex items-center px-1">
<CopyButton content={content} align={isUser ? 'right' : 'left'} />
) : (
/* AI plain text — full width, no bubble chrome */
<div className="flex flex-col w-full">
<div className="text-sm leading-relaxed select-text py-1">
<MarkdownRenderer content={content} isStreaming={isStreaming} />
</div>
<div className="h-6 flex items-center">
<CopyButton content={content} align="left" />
</div>
</div>
</div>
)}
</motion.div>
);
}
7 changes: 3 additions & 4 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ interface CopyButtonProps {
}

/**
* One-click copy button that lives in the reserved action bar below a chat bubble.
* Visible only when the parent container is hovered (via Tailwind `group`).
* Shows a checkmark for 1.5s on successful copy, then reverts to the copy icon.
* One-click copy button rendered below a chat message.
* Always visible; shows a checkmark for 1.5s on successful copy, then reverts.
* Clipboard failures are swallowed silently.
*/
export function CopyButton({ content, align }: CopyButtonProps) {
Expand Down Expand Up @@ -42,7 +41,7 @@ export function CopyButton({ content, align }: CopyButtonProps) {
>
<button
onClick={handleCopy}
className={`transition-opacity duration-150 text-white/40 hover:text-white/70 p-0.5 rounded cursor-pointer ${copied ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
className="transition-opacity duration-150 text-white/40 hover:text-white/70 p-0.5 rounded cursor-pointer"
aria-label={copied ? 'Copied' : 'Copy message'}
>
<AnimatePresence mode="wait" initial={false}>
Expand Down
Loading
Loading