fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

Description

@jeonghun-jj-lee

fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

Important

Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

Acceptance Criteria

  • The HarmonicDot is a single persistent element per turn — never remounts while running
  • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
  • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
  • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
  • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
  • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
  • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
  • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
  • A short label (<40 chars ending with :) followed by any content stays in one card
  • Consecutive list items / blockquotes never split (existing guard preserved)
  • No blank/empty bordered cards render in the timeline
  • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
  • Smooth scroll cancels immediately on user scroll gesture
  • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
  • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
  • Only one overlay dot exists at a time — gated on "is there a running turn"

Key Decisions

DecisionRationale
Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

Constraints & Invariants

  • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
  • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
  • Done-dots remain per-row (static, no overlay needed).
  • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
  • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
  • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
  • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

Files

FileChanges
packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
packages/app/src/design-polish.cssNo motion token changes
packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

Prior Art

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

      Description

      @jeonghun-jj-lee

      fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

      Important

      Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

      Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

      Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

      Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

      Acceptance Criteria

      • The HarmonicDot is a single persistent element per turn — never remounts while running
      • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
      • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
      • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
      • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
      • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
      • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
      • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
      • A short label (<40 chars ending with :) followed by any content stays in one card
      • Consecutive list items / blockquotes never split (existing guard preserved)
      • No blank/empty bordered cards render in the timeline
      • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
      • Smooth scroll cancels immediately on user scroll gesture
      • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
      • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
      • Only one overlay dot exists at a time — gated on "is there a running turn"

      Key Decisions

      DecisionRationale
      Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
      Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
      250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
      Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
      Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
      Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
      Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
      Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
      Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
      Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

      Constraints & Invariants

      • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
      • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
      • Done-dots remain per-row (static, no overlay needed).
      • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
      • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
      • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
      • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

      Files

      FileChanges
      packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
      packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
      packages/app/src/design-polish.cssNo motion token changes
      packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
      packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
      packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

      Prior Art

      Activity

      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

          Description

          @jeonghun-jj-lee

          fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

          Important

          Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

          Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

          Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

          Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

          Acceptance Criteria

          • The HarmonicDot is a single persistent element per turn — never remounts while running
          • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
          • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
          • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
          • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
          • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
          • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
          • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
          • A short label (<40 chars ending with :) followed by any content stays in one card
          • Consecutive list items / blockquotes never split (existing guard preserved)
          • No blank/empty bordered cards render in the timeline
          • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
          • Smooth scroll cancels immediately on user scroll gesture
          • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
          • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
          • Only one overlay dot exists at a time — gated on "is there a running turn"

          Key Decisions

          DecisionRationale
          Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
          Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
          250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
          Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
          Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
          Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
          Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
          Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
          Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
          Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

          Constraints & Invariants

          • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
          • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
          • Done-dots remain per-row (static, no overlay needed).
          • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
          • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
          • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
          • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

          Files

          FileChanges
          packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
          packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
          packages/app/src/design-polish.cssNo motion token changes
          packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
          packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
          packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

          Prior Art

          Activity

          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

              Description

              @jeonghun-jj-lee

              fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

              Important

              Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

              Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

              Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

              Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

              Acceptance Criteria

              • The HarmonicDot is a single persistent element per turn — never remounts while running
              • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
              • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
              • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
              • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
              • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
              • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
              • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
              • A short label (<40 chars ending with :) followed by any content stays in one card
              • Consecutive list items / blockquotes never split (existing guard preserved)
              • No blank/empty bordered cards render in the timeline
              • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
              • Smooth scroll cancels immediately on user scroll gesture
              • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
              • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
              • Only one overlay dot exists at a time — gated on "is there a running turn"

              Key Decisions

              DecisionRationale
              Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
              Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
              250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
              Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
              Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
              Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
              Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
              Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
              Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
              Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

              Constraints & Invariants

              • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
              • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
              • Done-dots remain per-row (static, no overlay needed).
              • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
              • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
              • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
              • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

              Files

              FileChanges
              packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
              packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
              packages/app/src/design-polish.cssNo motion token changes
              packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
              packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
              packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

              Prior Art

              Activity

              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

                  Description

                  @jeonghun-jj-lee

                  fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

                  Important

                  Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

                  Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

                  Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

                  Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

                  Acceptance Criteria

                  • The HarmonicDot is a single persistent element per turn — never remounts while running
                  • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
                  • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
                  • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
                  • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
                  • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
                  • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
                  • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
                  • A short label (<40 chars ending with :) followed by any content stays in one card
                  • Consecutive list items / blockquotes never split (existing guard preserved)
                  • No blank/empty bordered cards render in the timeline
                  • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
                  • Smooth scroll cancels immediately on user scroll gesture
                  • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
                  • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
                  • Only one overlay dot exists at a time — gated on "is there a running turn"

                  Key Decisions

                  DecisionRationale
                  Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
                  Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
                  250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
                  Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
                  Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
                  Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
                  Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
                  Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
                  Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
                  Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

                  Constraints & Invariants

                  • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
                  • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
                  • Done-dots remain per-row (static, no overlay needed).
                  • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
                  • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
                  • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
                  • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

                  Files

                  FileChanges
                  packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
                  packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
                  packages/app/src/design-polish.cssNo motion token changes
                  packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
                  packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
                  packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

                  Prior Art

                  Activity

                  Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

                      Description

                      @jeonghun-jj-lee

                      fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

                      Important

                      Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

                      Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

                      Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

                      Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

                      Acceptance Criteria

                      • The HarmonicDot is a single persistent element per turn — never remounts while running
                      • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
                      • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
                      • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
                      • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
                      • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
                      • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
                      • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
                      • A short label (<40 chars ending with :) followed by any content stays in one card
                      • Consecutive list items / blockquotes never split (existing guard preserved)
                      • No blank/empty bordered cards render in the timeline
                      • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
                      • Smooth scroll cancels immediately on user scroll gesture
                      • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
                      • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
                      • Only one overlay dot exists at a time — gated on "is there a running turn"

                      Key Decisions

                      DecisionRationale
                      Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
                      Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
                      250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
                      Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
                      Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
                      Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
                      Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
                      Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
                      Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
                      Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

                      Constraints & Invariants

                      • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
                      • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
                      • Done-dots remain per-row (static, no overlay needed).
                      • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
                      • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
                      • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
                      • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

                      Files

                      FileChanges
                      packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
                      packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
                      packages/app/src/design-polish.cssNo motion token changes
                      packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
                      packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
                      packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

                      Prior Art

                      Activity

                      Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

                          Description

                          @jeonghun-jj-lee

                          fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

                          Important

                          Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

                          Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

                          Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

                          Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

                          Acceptance Criteria

                          • The HarmonicDot is a single persistent element per turn — never remounts while running
                          • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
                          • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
                          • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
                          • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
                          • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
                          • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
                          • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
                          • A short label (<40 chars ending with :) followed by any content stays in one card
                          • Consecutive list items / blockquotes never split (existing guard preserved)
                          • No blank/empty bordered cards render in the timeline
                          • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
                          • Smooth scroll cancels immediately on user scroll gesture
                          • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
                          • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
                          • Only one overlay dot exists at a time — gated on "is there a running turn"

                          Key Decisions

                          DecisionRationale
                          Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
                          Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
                          250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
                          Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
                          Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
                          Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
                          Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
                          Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
                          Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
                          Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

                          Constraints & Invariants

                          • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
                          • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
                          • Done-dots remain per-row (static, no overlay needed).
                          • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
                          • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
                          • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
                          • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

                          Files

                          FileChanges
                          packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
                          packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
                          packages/app/src/design-polish.cssNo motion token changes
                          packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
                          packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
                          packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

                          Prior Art

                          Activity

                          Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll #628

                              Description

                              @jeonghun-jj-lee

                              fix(app): chat UI polish — persistent dot, rail alignment, cohesive segments, smooth scroll

                              Important

                              Problem — The chat timeline has four UX gaps that break the "seamless messaging" feel: (1) the spherical harmonics dot remounts/jumps on every new step and jitters during rapid streaming instead of staying calm, (2) the rail sits outside the composer's visible left edge, (3) prose messages over-segment into blank/orphaned boxes (labels split from their lists), and (4) the column teleports when new content arrives instead of gliding.

                              Approach — Hoist the running dot into a sticky-positioned turn-spanning overlay that only repositions on step boundaries; add left inset to align the rail inside the composer; strengthen the chunk-boundary heuristic to keep labels with their content; replace instant scrollToEnd() with a custom 180ms eased scroll.

                              Scope — Timeline rendering, scroll behaviour, and chunk segmentation. No backend, no protocol, no new dependencies.

                              Assumptions — Virtualization stays (tanstack/solid-virtual); existing entrance animations (300ms/8px rise/8px blur, 150ms prose-fragment) unchanged; overlay position driven by reactive memo from virtualizer measurements, updating only on step boundaries.

                              Acceptance Criteria

                              • The HarmonicDot is a single persistent element per turn — never remounts while running
                              • The dot uses CSS sticky positioning within a turn-spanning overlay; stays visible near viewport top during long streaming responses
                              • The dot only repositions on step boundaries (new rows: tool start, tool end, new text part) — NOT per prose-fragment chunk
                              • Dot slide uses a 250ms spring animation (cubic-bezier(0.34, 1.56, 0.64, 1)) with slight overshoot on each step transition
                              • On turn completion, the overlay dot crossfades out (150ms opacity) and the per-row done-dot appears underneath
                              • The rail line for the active turn is continuous (no per-row seams/gaps visible during streaming)
                              • The rail's left edge sits inside the composer's visible left boundary at md+ breakpoints
                              • Done-dots use deterministic offsets by group type (prose=21px, tool group=11px, single tool=16px, thinking=11px) — no TreeWalker
                              • A short label (<40 chars ending with :) followed by any content stays in one card
                              • Consecutive list items / blockquotes never split (existing guard preserved)
                              • No blank/empty bordered cards render in the timeline
                              • New content arriving while anchored to bottom causes a smooth ~180ms ease-out scroll, not a teleport
                              • Smooth scroll cancels immediately on user scroll gesture
                              • Entrance animations unchanged (300ms timeline-enter, 150ms prose-fragment-enter, concurrent with scroll)
                              • prefers-reduced-motion disables smooth scroll, dot spring, and crossfade (instant fallbacks)
                              • Only one overlay dot exists at a time — gated on "is there a running turn"

                              Key Decisions

                              DecisionRationale
                              Sticky overlay (not per-row dot)Virtualization makes per-row mounts unstable; overlay avoids remount. Sticky keeps dot visible during long responses without JS position updates.
                              Track per-row, not per-fragmentReduces dot movements from ~20-40 per turn to ~5-10. Each move marks a real phase change (tool→prose→tool). Eliminates jitter during rapid streaming.
                              250ms spring with overshoot for dot slideEach move is now an infrequent event worth marking. cubic-bezier(0.34, 1.56, 0.64, 1) gives physical character.
                              Crossfade on turn completion (not morph)Done-dot renders underneath; fade-out is 150ms, no SMIL choreography needed.
                              Reactive memo for overlay bounds (not coupled to scroll RAF)Simpler; only updates on step boundaries; one-frame lag imperceptible at 60fps.
                              Deterministic dotCentre — hardcoded per group typeEliminates measurement race; if CSS padding changes, one constant to update (accepted tradeoff).
                              Custom RAF scroll, 180ms ease-outNative behavior: 'smooth' is 500ms+ and browser-dependent; custom gives snappy control.
                              Entrance + scroll run concurrentlyFeels natural — column makes room while message arrives.
                              Left padding on row frame (md:pl-3)Simplest fix; narrows content from the left only, aligns rail with composer interior.
                              Label heuristic: <40 chars ending : + any contentCatches orphaned labels (common) without false-positiving on mid-sentence colons (length gate). Doesn't require next line to be a list — also protects label+paragraph.

                              Constraints & Invariants

                              • The overlay lives inside the scroll container (scrolls with content). A turn-spanning wrapper div covers from the Thinking row's item.start to the last AssistantPart row's item.start + item.size. HarmonicDot inside it is position: sticky; top: 12px.
                              • Overlay top and height only update when new rows appear or rows resize (step boundaries) — never per-fragment.
                              • Done-dots remain per-row (static, no overlay needed).
                              • Smooth scroll must not interfere with shouldAdjustScrollPositionOnItemSizeChange or prepend-anchor logic.
                              • Chunk splitting remains monotonic (once a boundary is emitted during streaming, it never un-emits).
                              • The 40-char label heuristic fires regardless of what follows (list, paragraph, quote) — the length gate prevents false positives on real sentences.
                              • Rail line + dot spring use identical timing (250ms, same bezier) so they move in lockstep.

                              Files

                              FileChanges
                              packages/app/src/pages/session/timeline/message-timeline.tsxTurn-spanning sticky overlay component, position memo (step-boundary only), smooth scroll helper, left padding on TimelineRowFrame, remove TreeWalker/ResizeObserver measurement
                              packages/app/src/pages/session/timeline/thought-rail.tsxRemove running-dot from per-row render, export deterministic dotCentreForGroup helper, keep done-dots per-row
                              packages/app/src/design-polish.cssNo motion token changes
                              packages/app/src/index.cssUpdate dot transition to 250ms spring bezier, overlay sticky styles, crossfade class
                              packages/session-ui/src/components/message-part-text.tsLabel heuristic (<40 chars + :) in chunkBoundaries, tail trim
                              packages/session-ui/src/components/message-part.tsxChunk trimming in ChunkedStreamMarkdown

                              Prior Art

                              Activity

                              Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions