Skip to content

feat(app): harmonic dot rail — persistent Thinking, traveling dot, ThinkingMeta - #262

Merged
jeonghun-jj-lee merged 37 commits into
local/amicodefrom
harmonic-dot
Aug 27, 2026
Merged

feat(app): harmonic dot rail — persistent Thinking, traveling dot, ThinkingMeta#262
jeonghun-jj-lee merged 37 commits into
local/amicodefrom
harmonic-dot

Conversation

@jeonghun-jj-lee

@jeonghun-jj-lee jeonghun-jj-lee commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

The harmonic dot on the thought rail now travels down as output lands.

Changes

Thinking row (first rail step)

  • Always persists (like Shell/Edit groups) — never disappears after turn completion
  • Shows just the bold "Thinking" label (no timer/tokens inline)
  • Harmonic dot animates here while no output has appeared yet
  • Becomes a static done dot once output lands and the dot travels down

Harmonic dot travels

  • Dot stays on Thinking until a settled AssistantPart row actually appears
  • Once output lands, dot moves to the last AssistantPart (current active step)
  • All dots become static when the turn completes
  • Fix: hasAssistantParts now checks projected timeline rows instead of raw sync parts — prevents premature dot departure when reasoning is withheld

ThinkingMeta row (tail)

  • New row at the bottom of every turn showing elapsed time + token count
  • Does NOT participate in the rail (no dot)
  • Uses the same 11px / text-muted styling as before
  • Live timer while running, final elapsed time when complete

Other

  • shouldRenderRail always returns true (Thinking guarantees ≥2 nodes)
  • Pill shapes constrained to horizontal-only for modes 1, 2, 7
  • Donut path geometry with evenodd fill for the ring shape

Tests

  • 6/6 timeline row tests passing
  • 41/41 harmonic geometry tests passing
  • Clean typecheck

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c8aa2b3e-da56-4246-88cf-ccc1bbdedb64


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jeonghun-jj-lee
jeonghun-jj-lee marked this pull request as ready for review August 27, 2026 17:24
…g dot

Pure geometry + timing module (mirrors wave-geometry.ts pattern):
- harmonicRadius(): polar sampling of Y_l^m silhouettes for 4 modes
- harmonicPath(): generates closed SVG paths with 64 equal-angle samples
- HARMONIC_PATHS: pre-computed paths for SMIL animate interpolation
- Timing constants: MODE_HOLD_MS=2300, ROTATION_PERIOD_MS=12000

All paths share identical command structure (M + 63L + Z) enabling
native SVG <animate attributeName="d"> interpolation.

Part of #261
…Line

- thought-rail.tsx: running dot now renders <HarmonicDot> (13px SVG with
  SMIL morph + CSS rotation) instead of static 7px span with box-shadow
- thinking-line.tsx: removed wave SVG and cycling gerund, kept only the
  meta row (elapsed, tokens, esc hint)
- amicode.css: simplified .amc-thinking from 2-col grid to inline-block
- index.css: replaced thought-rail-breathe with thought-rail-grow (150ms
  scale transition) and thought-rail-harmonic-rotate (12s linear)
- Respects prefers-reduced-motion: static 13px circle, no animation
- New HarmonicDot component + re-export shim (amicode-harmonic-dot.tsx)

Closes #261
Reworked the morph animation from a linear cycle through shapes to a
pulse rhythm where the sphere is home base:
  sphere (350ms) → morph out (175ms) → shape (500ms) → morph back (175ms)

Four pulses per cycle (4.8s total), each with a different harmonic at
a different orientation for maximum visual contrast:
  1. dumbbell vertical (0°)
  2. clover diagonal (45°)
  3. dumbbell horizontal (90°)
  4. pinched vertical (0°)

Implementation:
- harmonic-geometry.ts: added rotatedHarmonicPath(), PULSE_SEQUENCE,
  explicit-hold SMIL keyframes (17 values with duplicate-value holds)
- harmonic-dot.tsx: removed rotation <g> wrapper, simplified to single
  <path> with the new SMIL attributes
- index.css: removed thought-rail-harmonic-rotate keyframes

Tests: 32 pass (up from 17), covering pulse timing, rotation invariants,
SMIL structure, and the hold/morph pattern.
…r sequence

Added two new harmonic modes:
- Mode 5: trefoil — 3-lobe shape via (1+cos(3θ))/2 (Y_3 family, m=0)
- Mode 6: star-8 — 8-lobe shape via |cos(4θ)| (Y_4 family, m=4)

Expanded pulse sequence to 10 entries (was 4), cycling through all
5 usable shapes (modes 2–6) at 45°-increment rotations:
  clover@0° → rosette@45° → pinched@90° → trefoil@0° → star@45°
  → clover@45° → rosette@0° → pinched@135° → trefoil@45° → star@0°

No adjacent pulses share the same shape. Full cycle is now 12s
(10 × 1.2s per pulse). Removed dumbbell (mode 1) from all sequences.

MODE_COUNT: 5 → 7 (added trefoil + star-8)
PULSE_COUNT: 4 → 10
CYCLE_MS: 4800 → 12000
Added mode 1 (dumbbell) back to the pulse sequence, constrained to
45° and 90° rotations only — never vertical (0°), which avoids the
phallic silhouette. Same constraint applied to mode 2 (pinched).

New 10-pulse sequence uses all 6 shapes (modes 1–6):
  clover@0° → dumbbell@90° → rosette@45° → trefoil@0° → pinched@135°
  → star@45° → clover@45° → dumbbell@45° → rosette@0° → trefoil@45°

Added test: 'skinny shapes (modes 1, 2) are never at 0°'
…raint

Two changes:
1. Angles are now RANDOM — each HarmonicDot mount picks fresh rotation
   angles from the allowed 45°-increment set. The mode ORDER stays fixed
   (for visual contrast), but angles vary per streaming turn.

2. Fixed the vertical constraint — the dumbbell's natural orientation
   (rotation=0°) is HORIZONTAL in SVG coordinates (lobes at left/right).
   Rotation=90° makes it vertical (phallic). So the constraint is now:
   modes 1 and 2 exclude 90° and 270° (not 0°).

New exports: randomPulseSequence(), buildSmil(sequence)
Tests: 41 pass (added randomness verification, constraint checks)
New mode 7: |P_4^0(cosθ)| = |35cos⁴θ − 30cos²θ + 3|/8
Four lobes along the polar axis with two pinched waists between them.
Visually reads as a more complex version of the single pinch.

- Added to PULSE_MODES as the 11th pulse (cycle now 13.2s)
- Constrained to non-vertical angles (same as modes 1, 2)
- MODE_COUNT: 7 → 8, PULSE_COUNT: 10 → 11
The 3-lobe trefoil used (1+cos(3θ))/2, which is a rose curve, not
a Y_l^m cross-section. Removed from the pulse sequence entirely.

New 10-pulse sequence uses only proper harmonics:
  clover(Y_2^2) → dumbbell(Y_1^0) → rosette(Y_3^3) → double-pinch(P_4^0)
  → pinched(P_2^0) → star(Y_4^4) → clover → dumbbell → rosette → star

MODE_COUNT stays 8 (mode 5 slot still exists for compat, just unused).
PULSE_COUNT: 11 → 10, CYCLE_MS: 13200 → 12000.
The harmonic dot now appears immediately next to the timer during the
initial thinking phase (before content arrives). Once tokens start
flowing and the rail dot takes over, the inline dot departs with a
smooth CSS transition: translate(-16px, -14px) + scale(0.538) + fade
over 300ms — creating the illusion of the dot migrating from the
timer to the rail gutter.

- thinking-line.tsx: renders HarmonicDot in .amc-thinking-dot wrapper
- amicode.css: .amc-thinking-meta is now inline-flex for alignment;
  .amc-thinking-dot transitions on departure; reduced-motion honored
…ar on content

Removed the complex translate departure animation. The dot now:
- Sits in the rail gutter (absolute left: -20px, vertically centred)
  matching the thought-rail dot's horizontal position
- Simply unmounts (Show when={!hasContent()}) when tokens arrive
- The rail dot's grow animation handles the 'appearance' on content

No transition animation — clean disappear/appear handoff.
…itioning

The dot was not appearing in the rail gutter because it was rendered
inside ThinkingLine (which sits inside px-4 padding). Moved the
HarmonicDot rendering to TimelineThinkingRow in message-timeline.tsx,
positioned absolutely at the same coordinates as ThoughtRail dots:
  top: DEFAULT_DOT_CENTRE - HARMONIC_SIZE/2 = 4.5px
  left: LINE_X - HARMONIC_SIZE/2 = 4.5px

These anchor to session-turn-message-container (position: relative),
placing the dot in the padding gutter — exactly where the rail dots
appear on AssistantPart rows.

ThinkingLine reverted to pure metadata (no dot rendering).
Dot unmounts when tokens arrive (Show when={tokens == null}).
Moved dot rendering from TimelineThinkingRow to the Thinking case in
renderTimelineRow, directly inside session-turn-message-container
(which has position:relative and px-4 padding). Dot at left:0 places
it at the container's left padding-box edge — in the 16px gutter
before the text content starts.
The dot's resting state (sphere) is now a hollow ring (fill-opacity: 0,
stroke only). When a harmonic shape pulses in, fill-opacity transitions
to 1 (solid fill). Creates the visual of the ring 'filling up' with
each excitation and draining back to hollow.

Implementation: second <animate attributeName="fill-opacity"> on the
same <path>, sharing keyTimes/dur with the d-attribute animation.
Values: 0 during sphere holds, transitions 0→1 during morph-out,
1 during shape holds, 1→0 during morph-back.
…ricPrecision

Three fixes for the pixelated/line-through-ring issues:

1. Render SVG at 2x internal resolution (26-unit viewBox scaled to 13 CSS
   px) so the browser has finer path data to rasterize — eliminates the
   jagged sub-pixel aliasing at small sizes.

2. Remove shape-rendering="geometricPrecision" which was disabling the
   browser's anti-aliasing hinting and making the 13px stroke look blocky.

3. Add a background-coloured disc (--v2-background-bg-base) behind the
   animated path so the rail line terminates cleanly at the ring edge
   instead of showing through the hollow centre.

Stroke-width scaled to 4 viewBox units = 2 CSS px (unchanged visual
weight). Test updated for new viewBox bounds.
Reverts the 2x-viewBox and stroke-based ring — both were fuzzy at 13px.

New approach: the harmonic path is rendered as a SOLID FILL (original
crisp geometricPrecision rendering at 1:1 viewBox). A smaller circle
(r=3.5) filled with --v2-background-bg-base is layered ON TOP, punching
a visual hole to create a ~2.5px ring. This also masks the rail line
that runs behind the dot.

No stroke, no fill-opacity animation, no scaled viewBox — just two
layers that stay sharp at any DPI.
…l to centre

Three visual fixes:

1. Outer fill uses --accent (bright yellow) not --accent-edge (darker).

2. Inner background disc animates opacity in sync with the shape morph:
   opacity 1 during sphere holds (ring visible, masks rail line),
   transitions to 0 during harmonics (disc disappears, shape is solid).

3. Rail line renders AFTER the dot in DOM order so it paints on top of
   the SVG's background disc — the line now visually reaches the dot
   centre through the ring's hollow interior, rather than appearing to
   stop at the ring edge.
Replace opacity fade with radius animation on the inner background disc:
- During sphere hold: r = 3.5 (full ring visible, masks rail line)
- During morph-out: r shrinks 3.5 → 0 (ring closes as shape blooms)
- During harmonic hold: r = 0 (solid shape, no hole)
- During morph-back: r grows 0 → 3.5 (ring opens from centre)

Also restores rail line to render BEFORE the dot (behind in z-order)
so the inner disc properly masks it. INNER_R exported from geometry
module for shared use.
Replace the two-layer approach (solid path + background circle) with a
single filled path using fill-rule="evenodd". The path draws the outer
harmonic shape CW and an inner circle CCW — evenodd punches the hole.

Sphere state: outer circle + inner circle at r=3.5 → visible ring.
Harmonic state: outer shape + inner points collapsed to centre (r=0) → solid.

SMIL interpolates between donut paths point-by-point, so the ring
smoothly closes as the shape blooms and reopens when returning to sphere.
One path, one animation, no stroke, no layered elements — crisp filled
geometry at native 13px.
…he start

The Thinking row now renders a ThoughtRail (first=true, last=true,
running=true) when no assistant parts exist yet. This puts the harmonic
dot in the rail gutter at LINE_X from the very first moment of
streaming, consistent with where it'll appear on AssistantPart rows.

Once assistant parts arrive, assistantTokensForTurn becomes nonzero and
the Thinking row's rail disappears — the dot 'transports' to the last
AssistantPart row's rail as before.

Removes the standalone HarmonicDot from the Thinking case (and its
now-unused import).
…rcle

The donut path's evenodd hole is transparent, so the rail line behind
the SVG showed through the ring's interior. Fix: add a background-
coloured circle (--v2-background-bg-base) INSIDE the SVG, behind the
donut path. It fills the hole area, masking the line.

The circle's radius animates in sync with the donut's inner contour
(INNER_R → 0 → INNER_R) so it shrinks away during harmonic phases
(where the shape is solid and naturally covers the line) and grows
back for sphere phases (where the ring hole would expose it).

The rail line still reaches dotCentre geometrically — it connects
with harmonic shapes whose lobes extend inward past the ring edge.
Revert the Thinking-row rail experiment. The Thinking row now just
shows 'Thinking...' (the timer). No harmonic dot, no rail line.

The rail appears naturally once the first AssistantPart row arrives,
with its own running harmonic dot. Clean separation: status indicator
for thinking, rail for output.
The Thinking row now participates in the rail system (before any
assistant output arrives):

- rail() returns {first, last, running} → ThoughtRail renders the
  harmonic dot in the gutter (lone running step = dot only, no spine)
- railLabel() returns 'Thinking...' → ThoughtRailLabel renders the
  uppercase eyebrow label inline with the dot
- ThinkingLine (timer + tokens) renders as content below the label

Once the first AssistantPart row appears (assistantTokensForTurn > 0),
the Thinking row drops its rail and label — the AssistantPart rows
take over with their own rail dots and lines. No duplicate dots.
Two fixes:

1. The Thinking row's rail now disappears based on hasAssistantParts()
   (checks for any assistant message with parts) rather than
   assistantTokensForTurn() (only counts text tokens). Tool calls like
   shell commands have parts but no text tokens, so the old check left
   the Thinking rail visible alongside the shell command's rail dot.

2. 'Thinking...' label uses normal text-xs font (not uppercase mono).
   It's rendered as content inside TimelineThinkingRow with leading-[22px]
   to align with the dot, matching the faint metadata style — not the
   structural furniture style of tool labels like 'SHELL'.
…, Shell)

Renders as bold 'Thinking' label + muted timer inline, matching the
style of 'Explored 1 read', 'Edit message-timeline.tsx', 'Worked in
shell 2 commands'. Uses text-14-medium text-text-strong for the label
and font-normal text-text-base for the ThinkingLine detail.

No railLabel eyebrow — the content itself IS the first text line that
the rail dot aligns with.
…r after

Before assistant parts exist: renders as 'Thinking' (bold) + timer,
like a tool group header, with harmonic dot on the rail.

Once output starts streaming (hasAssistantParts = true): the 'Thinking'
label disappears, the rail drops, and just the timer + token count
remain as compact metadata below the rail content.
Both the rail() check and the hasOutput prop now use
assistantTokensForTurn — they flip together when actual output tokens
arrive. Previously rail used hasAssistantParts (fires earlier, when
parts land in store) while content used tokens, causing the dot to
disappear before the label.

Now 'Thinking' + harmonic dot stay visible until the model produces
real output tokens, then both disappear simultaneously as the
AssistantPart rows take over.
…run once)

SolidJS component bodies execute once — the early-return
'if (props.hasOutput) return null' never re-evaluated after mount.
Wrap in <Show when={!props.hasOutput}> so the DOM reactively
disappears when tokens arrive.
The Thinking row no longer hides — it stays visible on the rail just
like tool group rows do. Behaviour:

- Before output: first=true, last=true, running=true → harmonic dot
- After output:  first=true, last=false, running=false → done dot,
  line continues down to the AssistantPart rows

AssistantPart rows set first=false when the turn is running (Thinking
row sits above). Gap (pt-3) applied to the first AssistantPart in a
running turn so it spaces correctly below the Thinking step.
Elongated pill shapes looked odd at 45° diagonals. Restrict to 0°/180°
so they always render as a horizontal lozenge.
Moved Thinking row construction to push BEFORE AssistantPart rows in
rows.ts. Now the timeline renders as:

  ● Thinking  12s · ↑ 234 tokens   ← first rail step (done dot)
  |
  ● [prose output]                   ← subsequent steps
  |
  ✦ Working in shell  2 commands    ← running step (harmonic dot)

The Thinking row is always the first step of the turn. AssistantPart
rows come after it. First AssistantPart gets first=false when turn is
running (Thinking is above it) and pt-3 gap for spacing.
…t bottom

Architecture change to the thought rail:

- Thinking row: always the FIRST rail step, persists after turn
  completion (like Shell/Edit groups). Harmonic dot animates HERE
  while the turn is running. Shows just "Thinking" label.

- AssistantPart rows: always "done" dots (they represent completed
  output that has already appeared).

- ThinkingMeta row: NEW tail row, renders LAST in the turn, shows
  elapsed time + token count. Does NOT participate in the rail (no
  dot). Always visible at the bottom for running stats.

- shouldRenderRail: always true now (Thinking guarantees ≥2 nodes).

- computeTurnDuration: extracted helper in rows.ts for completed
  turn elapsed time.
The dot no longer pins permanently to the Thinking row. It travels:

- No output yet (reasoning withheld): dot animates on Thinking
- Output has landed: dot moves to the LAST AssistantPart
- Turn complete: all dots static

Rule: running = turnRunning && (lastAssistantPart for parts,
!hasOutput for Thinking). The dot only leaves Thinking once a
settled part actually appears on the rail.
…-thinking)

Removed the text-13 text-text-dimmed override. Both the live
ThinkingLine and the completed-state fallback now render under the
.amc-thinking class (font-size: 11px, --v2-text-text-muted) which
is what it always was.
hasAssistantParts() was checking raw parts (including withheld
reasoning) which could return true before any AssistantPart row
actually existed in the timeline. This caused the harmonic dot to
vanish from Thinking while no AssistantPart row had a dot yet —
leaving a partial white rail stub with no animation.

Fix: check the projected timelineRows() for actual AssistantPart
rows instead of raw sync parts. The dot now only leaves Thinking
when a renderable, settled row is visible on the rail.
@jeonghun-jj-lee jeonghun-jj-lee changed the title feat(app): replace wave loading indicator with spherical-harmonic morphing dot feat(app): harmonic dot rail — persistent Thinking, traveling dot, ThinkingMeta Aug 27, 2026
@jeonghun-jj-lee
jeonghun-jj-lee merged commit e0913dd into local/amicode Aug 27, 2026
2 of 3 checks passed
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