Skip to content

feat: UI polish — chat redesign, spiral loader, smooth upward animation - #21

Merged
quiet-node merged 8 commits into
mainfrom
worktree-delightful-waddling-lighthouse
Apr 3, 2026
Merged

feat: UI polish — chat redesign, spiral loader, smooth upward animation#21
quiet-node merged 8 commits into
mainfrom
worktree-delightful-waddling-lighthouse

Conversation

@quiet-node

Copy link
Copy Markdown
Owner

Summary

  • Chat view redesign: user messages in right-aligned bubbles, AI responses as plain text (no bubble, full-width), copy button always visible for both (left for AI, right for user)
  • 9-dot spiral loading indicator: replaces 3-dot bounce — brand color sweeps top-right → outer ring → center at 110ms/step, holds 200ms, pauses 500ms, then repeats; 3px dots with 3px gaps, no shadows
  • Smooth upward-anchor animation: eliminates all visual jumping when the overlay is spawned above a text selection
    • On submit, the native window immediately pre-expands to max height so streaming text fills a fixed canvas (no incremental upward repositioning)
    • Explicit minHeight is set on the outer container before every set_window_frame call to prevent a WKWebView viewport-lag flash that caused the ask bar to jump to the top of the window on submit

Test plan

  • All 240 Vitest tests pass (bun run test)
  • bun run validate-build passes with zero warnings/errors
  • Open overlay normally (no anchor) — input bar appears, chat works, copy buttons always visible
  • Open overlay above selected text (upward anchor) — submit a message and verify the window expands smoothly upward with no flash/jump
  • Stream a long response in upward-anchor mode — text fills from bottom, no repositioning jitter
  • Spiral indicator appears while waiting for first token, disappears when streaming starts

🤖 Generated with Claude Code

quiet-node and others added 6 commits April 3, 2026 09:48
User messages remain as right-aligned bubbles with the warm gradient.
AI messages now render as full-width plain text with no bubble chrome,
matching the ChatGPT/Claude convention. Copy button is always visible
for both message types (previously hover-only).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Replaces the old iMessage-style bouncing dots with a 3×3 grid.
The brand color sweeps the spiral path (top-right → outer ring →
center) at 110ms/step. The center dot holds for 200ms, all dots dim,
then a 500ms pause precedes the next cycle. No shadows or glows —
clean color transitions only. Positioned as plain text (no bubble)
to match the redesigned AI message layout.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Root cause: Streamdown opens empty block elements (<p>, list items)
before their content arrives, causing sudden ~20 px height jumps.
The ResizeObserver + requestAnimationFrame path adds 1-2 frames of
lag before set_window_frame repositions the window, leaving new
content briefly above the window boundary — visible as a jump.
Downward growth is immune because only height changes (origin fixed).

Fix: pre-expand the upward-anchor window by STREAM_BUFFER_PX (32 px)
while isGenerating is true. New reserved lines always land inside the
already-repositioned window, eliminating the clip-then-reveal cycle.
useLayoutEffect syncs the ref before paint so the buffer is applied
from the very first streaming frame.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
…jump

When an upward-anchor session begins generating (isGenerating → true), a new
useEffect immediately calls set_window_frame with the full max height before any
streaming tokens arrive. This gives the streaming text a fixed canvas, so
Streamdown's paragraph-level height reservations never cause incremental upward
repositioning — the window is already at its final size.

Removes the failed STREAM_BUFFER_PX approach (32px additive buffer) which still
produced visible jumps because ResizeObserver callbacks were racing with each
Streamdown block-element reservation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
When isChatMode becomes true on submit, ConversationView mounts with flex-1
causing the morphing container to grow to 600px. The ResizeObserver then calls
set_window_frame to expand the native window upward. Between that call and
WKWebView updating its viewport size, h-screen still reports the old small
height — with justify-end this temporarily places the content near the top of
the repositioned window, visible as a jarring flash.

Fix: before every set_window_frame call in the anchor path, explicitly set
minHeight on the outer container div. This forces the CSS layout to use the
target height immediately, so justify-end positions the content correctly
during the 1-2 frame WKWebView viewport update lag. minHeight is cleared when
a new session starts or the overlay hides.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node

Copy link
Copy Markdown
Owner Author

Code review

Found 2 issues:

  1. TypingIndicator restart cycle is untested — violates 100% coverage mandate (CLAUDE.md says "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.")

The inner schedule callback that resets the spiral after PAUSE_MS (lines 92–96) is never executed by any test. The furthest any test advances is STEP_MS * 8 + FADE_MS (880 ms), which triggers setDimmed(true) but stops before the PAUSE_MS (500 ms) inner timer fires. Lines 92–96 (currentStep = 0, setStep(0), setDimmed(false), schedule(tick, STEP_MS)) are unreachable under the current test suite, failing the 100% line/branch/statement requirement.

setDimmed(true);
schedule(() => {
currentStep = 0;
setStep(0);
setDimmed(false);
schedule(tick, STEP_MS);
}, PAUSE_MS);
}, FADE_MS);

Missing test coverage in:

it('all dots go idle after center hold (FADE_MS after reaching center)', () => {
const { container } = render(<TypingIndicator />);
act(() => {
vi.advanceTimersByTime(STEP_MS * 8 + FADE_MS);
});
const dots = container.querySelectorAll('.rounded-full');
Array.from(dots).forEach((dot) => {
expect(dot.classList.contains('bg-primary')).toBe(false);
});
});

  1. Dead group class on user bubble wrapper (CLAUDE.md says "zero tolerance for dead code")

The group class on the user message wrapper exists solely to drive group-hover visibility on CopyButton. This PR removes opacity-0 group-hover:opacity-100 from CopyButton (copy button is now always visible), but leaves group on the wrapper with a misleading comment "group enables legacy hover compat". Nothing in the subtree consumes group-hover anymore.

/* User bubble — max-width capped, group enables legacy hover compat */
<div className="group 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">

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

quiet-node and others added 2 commits April 3, 2026 11:47
- Add restart-cycle test for TypingIndicator: advances past PAUSE_MS to
  exercise the inner schedule callback (currentStep reset, setDimmed(false))
  that was previously an untested code path
- Update ChatBubble test to query max-w-[80%] directly instead of via the
  now-removed .group class
- Remove dead `group` class from user bubble wrapper in ChatBubble — the
  only consumer was group-hover on CopyButton which was removed when the
  button became always-visible
- Add v8 ignore start/stop around defensive null guards (outerContainerRef,
  timerRef, cancelled flag) that are unreachable in the jsdom/happy-dom
  test environment

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Mirrors the exact cargo llvm-cov invocation from pr-backend-tests.yml so
developers can run backend coverage locally with the same 100% line
enforcement as CI, instead of having to copy the raw cargo command from
the workflow file.

Also updates CLAUDE.md to reference the new script instead of the raw
cargo command.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
@quiet-node
quiet-node merged commit 4dfaba9 into main Apr 3, 2026
0 of 3 checks passed
@quiet-node
quiet-node deleted the worktree-delightful-waddling-lighthouse branch April 3, 2026 16:55
quiet-node added a commit that referenced this pull request Apr 10, 2026
…on (#21)

* feat: redesign chat view to industry-standard assistant layout

User messages remain as right-aligned bubbles with the warm gradient.
AI messages now render as full-width plain text with no bubble chrome,
matching the ChatGPT/Claude convention. Copy button is always visible
for both message types (previously hover-only).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat: replace 3-dot bounce with 9-dot spiral loading indicator

Replaces the old iMessage-style bouncing dots with a 3×3 grid.
The brand color sweeps the spiral path (top-right → outer ring →
center) at 110ms/step. The center dot holds for 200ms, all dots dim,
then a 500ms pause precedes the next cycle. No shadows or glows —
clean color transitions only. Positioned as plain text (no bubble)
to match the redesigned AI message layout.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* style: shrink spiral loading dots to 3px with 3px gaps

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: smooth upward-anchor window growth during LLM streaming

Root cause: Streamdown opens empty block elements (<p>, list items)
before their content arrives, causing sudden ~20 px height jumps.
The ResizeObserver + requestAnimationFrame path adds 1-2 frames of
lag before set_window_frame repositions the window, leaving new
content briefly above the window boundary — visible as a jump.
Downward growth is immune because only height changes (origin fixed).

Fix: pre-expand the upward-anchor window by STREAM_BUFFER_PX (32 px)
while isGenerating is true. New reserved lines always land inside the
already-repositioned window, eliminating the clip-then-reveal cycle.
useLayoutEffect syncs the ref before paint so the buffer is applied
from the very first streaming frame.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: pre-expand to max height on streaming start to eliminate upward jump

When an upward-anchor session begins generating (isGenerating → true), a new
useEffect immediately calls set_window_frame with the full max height before any
streaming tokens arrive. This gives the streaming text a fixed canvas, so
Streamdown's paragraph-level height reservations never cause incremental upward
repositioning — the window is already at its final size.

Removes the failed STREAM_BUFFER_PX approach (32px additive buffer) which still
produced visible jumps because ResizeObserver callbacks were racing with each
Streamdown block-element reservation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: eliminate ask-bar jump on submit in upward-anchor mode

When isChatMode becomes true on submit, ConversationView mounts with flex-1
causing the morphing container to grow to 600px. The ResizeObserver then calls
set_window_frame to expand the native window upward. Between that call and
WKWebView updating its viewport size, h-screen still reports the old small
height — with justify-end this temporarily places the content near the top of
the repositioned window, visible as a jarring flash.

Fix: before every set_window_frame call in the anchor path, explicitly set
minHeight on the outer container div. This forces the CSS layout to use the
target height immediately, so justify-end positions the content correctly
during the 1-2 frame WKWebView viewport update lag. minHeight is cleared when
a new session starts or the overlay hides.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: restore 100% coverage and remove dead group class

- Add restart-cycle test for TypingIndicator: advances past PAUSE_MS to
  exercise the inner schedule callback (currentStep reset, setDimmed(false))
  that was previously an untested code path
- Update ChatBubble test to query max-w-[80%] directly instead of via the
  now-removed .group class
- Remove dead `group` class from user bubble wrapper in ChatBubble — the
  only consumer was group-hover on CopyButton which was removed when the
  button became always-visible
- Add v8 ignore start/stop around defensive null guards (outerContainerRef,
  timerRef, cancelled flag) that are unreachable in the jsdom/happy-dom
  test environment

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* chore: add bun run test:backend:coverage script

Mirrors the exact cargo llvm-cov invocation from pr-backend-tests.yml so
developers can run backend coverage locally with the same 100% line
enforcement as CI, instead of having to copy the raw cargo command from
the workflow file.

Also updates CLAUDE.md to reference the new script instead of the raw
cargo command.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

---------

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
quiet-node added a commit that referenced this pull request Apr 10, 2026
…on (#21)

* feat: redesign chat view to industry-standard assistant layout

User messages remain as right-aligned bubbles with the warm gradient.
AI messages now render as full-width plain text with no bubble chrome,
matching the ChatGPT/Claude convention. Copy button is always visible
for both message types (previously hover-only).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat: replace 3-dot bounce with 9-dot spiral loading indicator

Replaces the old iMessage-style bouncing dots with a 3×3 grid.
The brand color sweeps the spiral path (top-right → outer ring →
center) at 110ms/step. The center dot holds for 200ms, all dots dim,
then a 500ms pause precedes the next cycle. No shadows or glows —
clean color transitions only. Positioned as plain text (no bubble)
to match the redesigned AI message layout.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* style: shrink spiral loading dots to 3px with 3px gaps

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: smooth upward-anchor window growth during LLM streaming

Root cause: Streamdown opens empty block elements (<p>, list items)
before their content arrives, causing sudden ~20 px height jumps.
The ResizeObserver + requestAnimationFrame path adds 1-2 frames of
lag before set_window_frame repositions the window, leaving new
content briefly above the window boundary — visible as a jump.
Downward growth is immune because only height changes (origin fixed).

Fix: pre-expand the upward-anchor window by STREAM_BUFFER_PX (32 px)
while isGenerating is true. New reserved lines always land inside the
already-repositioned window, eliminating the clip-then-reveal cycle.
useLayoutEffect syncs the ref before paint so the buffer is applied
from the very first streaming frame.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: pre-expand to max height on streaming start to eliminate upward jump

When an upward-anchor session begins generating (isGenerating → true), a new
useEffect immediately calls set_window_frame with the full max height before any
streaming tokens arrive. This gives the streaming text a fixed canvas, so
Streamdown's paragraph-level height reservations never cause incremental upward
repositioning — the window is already at its final size.

Removes the failed STREAM_BUFFER_PX approach (32px additive buffer) which still
produced visible jumps because ResizeObserver callbacks were racing with each
Streamdown block-element reservation.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: eliminate ask-bar jump on submit in upward-anchor mode

When isChatMode becomes true on submit, ConversationView mounts with flex-1
causing the morphing container to grow to 600px. The ResizeObserver then calls
set_window_frame to expand the native window upward. Between that call and
WKWebView updating its viewport size, h-screen still reports the old small
height — with justify-end this temporarily places the content near the top of
the repositioned window, visible as a jarring flash.

Fix: before every set_window_frame call in the anchor path, explicitly set
minHeight on the outer container div. This forces the CSS layout to use the
target height immediately, so justify-end positions the content correctly
during the 1-2 frame WKWebView viewport update lag. minHeight is cleared when
a new session starts or the overlay hides.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: restore 100% coverage and remove dead group class

- Add restart-cycle test for TypingIndicator: advances past PAUSE_MS to
  exercise the inner schedule callback (currentStep reset, setDimmed(false))
  that was previously an untested code path
- Update ChatBubble test to query max-w-[80%] directly instead of via the
  now-removed .group class
- Remove dead `group` class from user bubble wrapper in ChatBubble — the
  only consumer was group-hover on CopyButton which was removed when the
  button became always-visible
- Add v8 ignore start/stop around defensive null guards (outerContainerRef,
  timerRef, cancelled flag) that are unreachable in the jsdom/happy-dom
  test environment

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* chore: add bun run test:backend:coverage script

Mirrors the exact cargo llvm-cov invocation from pr-backend-tests.yml so
developers can run backend coverage locally with the same 100% line
enforcement as CI, instead of having to copy the raw cargo command from
the workflow file.

Also updates CLAUDE.md to reference the new script instead of the raw
cargo command.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

---------

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
quiet-node added a commit that referenced this pull request Apr 11, 2026
…on (#21)

* feat: redesign chat view to industry-standard assistant layout

User messages remain as right-aligned bubbles with the warm gradient.
AI messages now render as full-width plain text with no bubble chrome,
matching the ChatGPT/Claude convention. Copy button is always visible
for both message types (previously hover-only).

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* feat: replace 3-dot bounce with 9-dot spiral loading indicator

Replaces the old iMessage-style bouncing dots with a 3×3 grid.
The brand color sweeps the spiral path (top-right → outer ring →
center) at 110ms/step. The center dot holds for 200ms, all dots dim,
then a 500ms pause precedes the next cycle. No shadows or glows —
clean color transitions only. Positioned as plain text (no bubble)
to match the redesigned AI message layout.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* style: shrink spiral loading dots to 3px with 3px gaps

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: smooth upward-anchor window growth during LLM streaming

Root cause: Streamdown opens empty block elements (<p>, list items)
before their content arrives, causing sudden ~20 px height jumps.
The ResizeObserver + requestAnimationFrame path adds 1-2 frames of
lag before set_window_frame repositions the window, leaving new
content briefly above the window boundary — visible as a jump.
Downward growth is immune because only height changes (origin fixed).

Fix: pre-expand the upward-anchor window by STREAM_BUFFER_PX (32 px)
while isGenerating is true. New reserved lines always land inside the
already-repositioned window, eliminating the clip-then-reveal cycle.
useLayoutEffect syncs the ref before paint so the buffer is applied
from the very first streaming frame.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: pre-expand to max height on streaming start to eliminate upward jump

When an upward-anchor session begins generating (isGenerating → true), a new
useEffect immediately calls set_window_frame with the full max height before any
streaming tokens arrive. This gives the streaming text a fixed canvas, so
Streamdown's paragraph-level height reservations never cause incremental upward
repositioning — the window is already at its final size.

Removes the failed STREAM_BUFFER_PX approach (32px additive buffer) which still
produced visible jumps because ResizeObserver callbacks were racing with each
Streamdown block-element reservation.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: eliminate ask-bar jump on submit in upward-anchor mode

When isChatMode becomes true on submit, ConversationView mounts with flex-1
causing the morphing container to grow to 600px. The ResizeObserver then calls
set_window_frame to expand the native window upward. Between that call and
WKWebView updating its viewport size, h-screen still reports the old small
height — with justify-end this temporarily places the content near the top of
the repositioned window, visible as a jarring flash.

Fix: before every set_window_frame call in the anchor path, explicitly set
minHeight on the outer container div. This forces the CSS layout to use the
target height immediately, so justify-end positions the content correctly
during the 1-2 frame WKWebView viewport update lag. minHeight is cleared when
a new session starts or the overlay hides.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* fix: restore 100% coverage and remove dead group class

- Add restart-cycle test for TypingIndicator: advances past PAUSE_MS to
  exercise the inner schedule callback (currentStep reset, setDimmed(false))
  that was previously an untested code path
- Update ChatBubble test to query max-w-[80%] directly instead of via the
  now-removed .group class
- Remove dead `group` class from user bubble wrapper in ChatBubble — the
  only consumer was group-hover on CopyButton which was removed when the
  button became always-visible
- Add v8 ignore start/stop around defensive null guards (outerContainerRef,
  timerRef, cancelled flag) that are unreachable in the jsdom/happy-dom
  test environment

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

* chore: add bun run test:backend:coverage script

Mirrors the exact cargo llvm-cov invocation from pr-backend-tests.yml so
developers can run backend coverage locally with the same 100% line
enforcement as CI, instead of having to copy the raw cargo command from
the workflow file.

Also updates CLAUDE.md to reference the new script instead of the raw
cargo command.

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>

---------

Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant