From 87cd9d19e652b4db76029522b1b3f3dcf4ef20bb Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Tue, 20 Oct 2026 07:36:34 +0300 Subject: [PATCH] web: a "see new messages" pill over the composer when a chat is scrolled up Scrolled up from the end of a chat (more than 40 px), a pill floats over the composer, on a phone above the drawer's handle, in the composer's tone with only a hairline shadow: "see new messages" once the end has changed since the reader left it (a new last block, or more of a live reply; older pages loading above do not count), "back to latest" until then. It runs back to the end in 250 ms however far (the browser's smooth scroll crawls over a long chat), re-measuring the end each frame, and a wheel or touch takes over. Signed-off-by: dawn <90008@klbr.net> --- robot-docs/web.md | 2 +- web/src/lib/chat/Chat.svelte | 83 +++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/robot-docs/web.md b/robot-docs/web.md index 61b026b9..a032031c 100644 --- a/robot-docs/web.md +++ b/robot-docs/web.md @@ -25,7 +25,7 @@ The client shares the terminal's voice. `moods.ts` is a port of `cli/internal/tu Markdown (`markdown.ts`) also understands maths and diagrams: `$…$`, `\(…\)` and `$$…$$` / `\[…\]` (a dollar amount like "$5 or $10" stays prose: the opening `$` must touch its text and no digit may follow the closing one) and ```` ```mermaid ```` blocks. They render as placeholders carrying their source, and `enhance.ts` draws them after the message mounts: KaTeX for maths (`trust: false`), Pele (pele.run) for diagrams, themed with `--pele-*` from the canvas tokens, its SVG kept to drawing (no scripts, foreign objects, event attributes or outside links). Both load only when a message needs them. -**Notes** (`nodes/NoteNode.svelte`, `anchors.ts`, `notes.ts`, `Tethers.svelte`) are small text boxes on the canvas, saved with the board. Double-click empty canvas or press N for a loose one; the composer's pop-out button moves the draft into a note tied to its chat, for writing at length. Dragging a note's grip onto something ties the note to it: a window, a message or step in a chat (`data-entry`), or a line in a diff (found by the diff's `data-diff` key and the row's number inside Pierre's shadow root); dropping it on empty canvas sets it loose. Until a chat's first page is in and scrolled to its end, the thread stays hidden behind a skeleton shaped like the end of a typical chat (measured at phone width: a work line, a message, more work, the reply's last lines and its sign-off), so it never shows its top and then jumps. Tables in messages scroll sideways instead of squeezing their columns. A review names each file by its last two path segments (`…/chat/Chat.svelte`), the full path as its title. +**Notes** (`nodes/NoteNode.svelte`, `anchors.ts`, `notes.ts`, `Tethers.svelte`) are small text boxes on the canvas, saved with the board. Double-click empty canvas or press N for a loose one; the composer's pop-out button moves the draft into a note tied to its chat, for writing at length. Dragging a note's grip onto something ties the note to it: a window, a message or step in a chat (`data-entry`), or a line in a diff (found by the diff's `data-diff` key and the row's number inside Pierre's shadow root); dropping it on empty canvas sets it loose. Until a chat's first page is in and scrolled to its end, the thread stays hidden behind a skeleton shaped like the end of a typical chat (measured at phone width: a work line, a message, more work, the reply's last lines and its sign-off), so it never shows its top and then jumps. A chat follows its end as it grows unless the reader has scrolled up (more than 40 px from it); then a pill floats over the composer (on a phone, above the drawer's handle): "see new messages" once the end has changed since they left it (a new last block, or more of a live reply; older pages loading above do not count), "back to latest" until then, and it scrolls back down. Tables in messages scroll sideways instead of squeezing their columns. A review names each file by its last two path segments (`…/chat/Chat.svelte`), the full path as its title. While the grip is dragged, what it would land on is outlined (`pick` in `anchors.ts`); the drag follows the pointer on the whole document, so a note redrawn mid-drag, or dismissed, never leaves the tie or its outline behind. The target and a quote of it (the diff lines, or the message's first 600 characters) are kept on the note, so what it was about survives scrolling and closing. A line runs from each tied note to its target, and a target that is a passage (diff lines, a message, a step) is tinted while on screen; the note in hand, selected or under the pointer, draws its line and tint strongest. The line is measured in screen space every frame while any note is tied, held inside the window and whatever scrolls it. Notes float above windows (`zIndex`). A tied note sends on its own (⌘↵), from its chat's "send N notes" button, or with its review; notes for one session go as one message, each quoting what it was tied to (a note tied to a chat window itself is just a message). Sent notes stay on the board, quieter, until dismissed. diff --git a/web/src/lib/chat/Chat.svelte b/web/src/lib/chat/Chat.svelte index bba1f4f4..2df542da 100644 --- a/web/src/lib/chat/Chat.svelte +++ b/web/src/lib/chat/Chat.svelte @@ -57,8 +57,14 @@ let scroller = $state(); let top = $state(); - let pinned = true; + let pinned = $state(true); let dockHeight = $state(0); + // The end of the chat as it was when the reader scrolled up from it, so a + // new message or more of a reply below can be told from older ones loading + // above. + const end = $derived(`${transcript.groups.at(-1)?.key ?? ''}:${transcript.live.reduce((length, item) => length + item.text.length, 0)}`); + let leftAt = $state(null); + const unseen = $derived(!pinned && leftAt !== null && end !== leftAt); let settled = $state(false); let older = false; @@ -129,7 +135,42 @@ }); function scrolled() { - if (scroller) pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40; + if (!scroller) return; + const was = pinned; + pinned = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 40; + if (was && !pinned) leftAt = end; + else if (pinned) leftAt = null; + } + + // Back to the end in a quarter second however far it is (the browser's own + // smooth scroll crawls over a long chat); a wheel or touch takes over. + const LATEST_MS = 250; + function latest() { + const element = scroller; + if (!element) return; + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + element.scrollTop = element.scrollHeight; + return; + } + const from = element.scrollTop; + const start = performance.now(); + let frame = 0; + const stop = () => { + cancelAnimationFrame(frame); + element.removeEventListener('wheel', stop); + element.removeEventListener('touchstart', stop); + }; + element.addEventListener('wheel', stop, { passive: true }); + element.addEventListener('touchstart', stop, { passive: true }); + const step = (now: number) => { + const t = Math.min(1, (now - start) / LATEST_MS); + // Measured each frame: the end can grow while it travels. + const to = element.scrollHeight - element.clientHeight; + element.scrollTop = from + (to - from) * (1 - (1 - t) ** 3); + if (t < 1) frame = requestAnimationFrame(step); + else stop(); + }; + frame = requestAnimationFrame(step); } // Notes tied to this chat or anything in it go as one message. @@ -289,6 +330,12 @@
+ {#if settled && !pinned} + + {/if} {@render handle?.()} {#if tied.length} @@ -333,6 +380,37 @@ left: 0; padding: 0 10px 8px; } + /* Floats over the end of the thread while it is scrolled up, in the + composer's tone; on a phone, above the drawer's handle. */ + .latest { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + z-index: 2; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 13px 6px 10px; + color: var(--text); + font: inherit; + font-size: 12.5px; + white-space: nowrap; + background: var(--field); + border: 0; + border-radius: 999px; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.1); + cursor: pointer; + transform: translateX(-50%); + } + .latest:hover { + background: color-mix(in oklab, var(--field), var(--text) 6%); + } + .latest svg { + color: var(--accent); + } + .inline .latest { + bottom: calc(100% + 34px); + } /* Until the first page is in and scrolled to its end, the thread stays unseen and the skeleton holds its place, so it never shows its top and then jumps. */ @@ -638,6 +716,7 @@ color: var(--danger); } .dock { + position: relative; display: flex; flex-direction: column; flex: none; -- knotmirror 0.0.1