diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 681e37026..19fbcf786 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -150,6 +150,120 @@ } } +/* ── prompt bubble ground — per-scheme REALISATION ── + On LIGHT the user's words are the INVERSE of the ground — the website's + ink bubble, verbatim (the site is light-only, so the inverse grammar is a + light-mode truth). On DARK any light bubble read harsh at chat scale: + Kate walked the inverse down 100%→85%→70% luminance (7b1ff4e, 1ef7c41) + and it still glared — dark wants LOW contrast, not an inverse. The dark + bubble is therefore the theme's own quiet elevation: layer-02 on the + ground (≈1.5:1, a whisper of lift — stock #2e2e2e on #161616) under full + base ink (≈12:1, unchanged readability). */ +:root, +[data-color-scheme="light"] { + --prompt-bubble-bg: var(--v2-background-bg-inverse); + --prompt-bubble-ink: var(--v2-text-text-inverse); +} +[data-color-scheme="dark"] { + --prompt-bubble-bg: var(--v2-background-bg-layer-02); + --prompt-bubble-ink: var(--v2-text-text-base); +} +@media (prefers-color-scheme: dark) { + :root:not([data-color-scheme="light"]) { + --prompt-bubble-bg: var(--v2-background-bg-layer-02); + --prompt-bubble-ink: var(--v2-text-text-base); + } +} + +/* ── entrance motion — timeline blocks land whole ── + New timeline rows (Kate 2026-08-24) enter as complete blocks: a small rise + and fade, fast and crisp, decided per row-key exactly once (the timeline + guards against virtual-row remount replays). Opacity+transform only — the + virtualizer owns row position and measured height, so entrance motion must + never touch layout. Reduced motion keeps the fade, drops the rise. */ +:root { + /* IDENTICAL to the website's chat grammar (harmoniqs-ai demo/parts.jsx, + verified against the production chunk): enter from below and slightly + blurred — HIDDEN {opacity:0, y:8, blur(8px)} → SHOWN over 0.3s on + [.2,0,.2,1] (soft attack, soft landing); leave UPWARD and re-blurred + over 0.2s on [.4,0,1,1] (exits shorter than entrances — the direction + says "arriving" vs "being cleared"). */ + --motion-enter-duration: 0.3s; + --motion-enter-rise: 8px; + --motion-enter-blur: 8px; + --motion-enter-ease: cubic-bezier(0.2, 0, 0.2, 1); + --motion-exit-duration: 0.2s; + --motion-exit-ease: cubic-bezier(0.4, 0, 1, 1); +} +/* fill BACKWARDS, not both: backwards still covers the cascade's delay phase + (rows hold at the hidden frame until their turn), while after the animation + the element returns to its natural styles — `both` left a permanent + filter:blur(0) on every entered row, a forced stacking context + raster + layer for the rest of the session. */ +[data-timeline-enter] { + animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards; +} +/* chunk entrances inside a streaming prose row — the same grammar, skinned + onto the attribute session-ui's ChunkedStreamMarkdown sets per settled + chunk (Kate 2026-08-25: replies land in chunks, not one final block). */ +[data-part-enter] { + animation: timeline-enter var(--motion-enter-duration) var(--motion-enter-ease) backwards; +} +/* The open cascade holds at frame 0 (opacity 0, risen, blurred) until the + timeline has settled at the bottom — released by removing + [data-entrance-pending] (message-timeline.tsx entranceReady). Also keeps + the draft handoff's entrance from playing behind the cold-mount veil. */ +[data-entrance-pending] [data-timeline-enter] { + animation-play-state: paused; +} +@keyframes timeline-enter { + from { + opacity: 0; + transform: translateY(var(--motion-enter-rise)); + filter: blur(var(--motion-enter-blur)); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0px); + } +} +/* ── prose fragment cards ── every fragment Amico relays is its OWN bordered + card, a message within the chat (Kate 2026-08-25; supersedes the + one-container-per-reply segment). Each chunk of a streamed reply arrives + as its own card with its own entrance; history splits identically so the + cards persist. Chips/receipts keep their borderless chip grammar. The + hairline is the theme's default border, the radius the brand 4px. */ +[data-slot="text-part-body"]:has([data-prose-fragment]) { + display: flex; + flex-direction: column; + gap: 8px; +} +[data-prose-fragment] { + border: var(--border-width) solid var(--v2-border-border-base); + border-radius: var(--radius-lg); + padding: 10px 14px; +} +/* markdown's own block margins would pad the card's top and bottom twice */ +[data-prose-fragment] :first-child { + margin-top: 0; +} +[data-prose-fragment] :last-child { + margin-bottom: 0; +} + +/* No exit animation. The site's GONE grammar (up + re-blur) was tried as a + positioned ghost clone and misfired — a body-appended clone escapes the + theme scope and the virtualizer relayouts under the captured rect. The + --motion-exit-* tokens above stay as the documented grammar should a real + FLIP-based exit ever be built. */ +@media (prefers-reduced-motion: reduce) { + :root { + --motion-enter-rise: 0px; + --motion-enter-blur: 0px; + } +} + /* ── inline code: readable in both schemes ── */ :not(pre) > code { color: #3d4451; @@ -176,6 +290,14 @@ button, [role="button"], a, input, textarea, [data-slot="card"] { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } + /* The timeline entrance deliberately KEEPS its fade under reduced motion — + the rise is already zeroed via --motion-enter-rise above, and without the + fade a withheld block would pop in with no signal at all. The global + 0.01ms reset would otherwise annihilate it. */ + [data-timeline-enter], + [data-part-enter] { + animation-duration: var(--motion-enter-duration) !important; + } } /* ============================================================ diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 27e167ca1..35334f61a 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -41,7 +41,13 @@ export function NewSessionView(props: { data-component="session-new-design" class="relative flex-1 min-h-0 overflow-hidden rounded-md bg-v2-background-bg-deep" > -
+ {/* amicode: centre the mark+composer group optically — flex centring + with the same pb-lift the start screen uses (session-new-view), + so the group tracks the panel's height. Upstream's fixed + top-[25.375%] anchor was tuned for a wide desktop window; in the + tall Amicode webview it beached the group in the upper third with + a hold of empty space below. */} +
{/* amicode: brand mark + wordmark (recovered composition — the H-robot over the AMICODE wordmark, Kate's Kimi-clean ordering; diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2d5fc0378..e46d17a04 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -37,9 +37,11 @@ import { MessageDivider, Part as MessagePart, partDefaultOpen, + renderable, ShellToolGroup, type UserActions, } from "@opencode-ai/session-ui/message-part" +import { readPartText, settledChunkBoundary } from "@opencode-ai/session-ui/message-part-text" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" @@ -374,6 +376,11 @@ export function MessageTimeline(props: { // Hide the scroll container for the first frame on cold-bottom-mount to prevent // a flash of content at the top before scrollToEnd fires. const [scrollReady, setScrollReady] = createSignal(!coldBottomMount) + // The open cascade holds until the virtualizer has settled at the bottom — + // entering rows sit paused at opacity 0 (see [data-entrance-pending] in + // design-polish.css) so the entrance never plays behind the opacity veil or + // during the initial scroll jump. Flipped one frame after the mount scroll. + const [entranceReady, setEntranceReady] = createSignal(false) const platform = usePlatform() const [listRoot, setListRoot] = createSignal() @@ -440,6 +447,24 @@ export function MessageTimeline(props: { return language.t("command.session.new") }) const showHeader = createMemo(() => !!(titleValue() || parentID())) + // The chunk gate for a streaming prose tail (Kate 2026-08-25: replies land + // in chunks). rows.ts withholds the tail only until its FIRST chunk settles + // — but the freshest streamed text lives in part_text_accum_delta (part.text + // lags during delta streaming), which the pure row construction cannot see. + // Computed here as a boolean memo so the per-token recomputation stops at an + // unchanged value instead of rebuilding the whole row projection per delta. + const tailProseSettled = createMemo(() => { + const last = sessionMessages().findLast( + (message): message is AssistantMessage => message.role === "assistant", + ) + if (!last || typeof last.time.completed === "number") return false + const tail = getMsgParts(last.id) + .filter((part) => renderable(part, settings.general.showReasoningSummaries())) + .at(-1) + if (!tail || tail.type !== "text" || tail.time?.end) return false + const text = readPartText(sync().data.part_text_accum_delta, tail) + return settledChunkBoundary(text) > 0 + }) const projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, @@ -448,6 +473,7 @@ export function MessageTimeline(props: { status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, inlineComments: settings.general.newLayoutDesigns, + tailProseSettled, }) const activeMessageID = projection.activeMessageID const assistantMessagesByParent = projection.assistantMessagesByParent @@ -458,6 +484,52 @@ export function MessageTimeline(props: { const timelineRowByKey = projection.rowByKey const timelineRows = projection.rows + // Entrance bookkeeping (Kate 2026-08-24/25: "all the elements fade in from + // the bottom", blocks land whole, crisply). Two moments animate, nothing + // else ever does: + // + // 1. THE OPEN CASCADE — every session open / switch / reload cascades the + // initially rendered rows in, staggered top-to-bottom. Rows arming + // inside the first CASCADE_WINDOW_MS after the per-session reset are the + // initial batch; each takes an animation-delay step. The cascade holds + // paused behind [data-entrance-pending] until the virtualizer settles at + // the bottom, which also removes the old unanimated flash-jump (history + // used to paint one frame at the top, then teleport to the bottom). + // + // 2. THE LIVE TURN — after the open, only rows of the ACTIVE turn enter + // (the just-sent bubble, Thinking, the settled reply blocks). Settled + // history joining later — scroll-back remounts, pagination prepends, + // far jumps — lands silently by rule, which closes the whole class of + // replay/burn bugs the per-key ledger alone could not (an audit found + // prepends animating a full page, and off-screen mounts burning their + // once-only entrance invisibly). + // + // Each key still animates at most once (virtual rows remount on every + // scroll-back, so mount alone must never trigger the entrance). + const CASCADE_WINDOW_MS = 600 + const CASCADE_STEP_MS = 40 + const CASCADE_MAX_STEPS = 12 + let enteredFor: string | undefined + let enteredAt = 0 + let cascadeStep = 0 + const enteredKeys = new Set() + const shouldAnimateEnter = (rowKey: string, row: TimelineRow.TimelineRow): number | false => { + const sid = sessionID() + if (enteredFor !== sid) { + enteredFor = sid + enteredKeys.clear() + enteredAt = performance.now() + cascadeStep = 0 + } + if (enteredKeys.has(rowKey)) return false + enteredKeys.add(rowKey) + if (performance.now() - enteredAt < CASCADE_WINDOW_MS) { + return Math.min(cascadeStep++, CASCADE_MAX_STEPS) * CASCADE_STEP_MS + } + if (row.userMessageID === activeMessageID()) return 0 + return false + } + let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined let prependLoading = false @@ -695,6 +767,9 @@ export function MessageTimeline(props: { if (renderOverscan() < 20) setRenderOverscan(20) if (props.shouldAnchorBottom()) virtualizer.scrollToEnd() if (!scrollReady()) setScrollReady(true) + // one more frame so measurement-driven scroll corrections land before + // the cascade is released + requestAnimationFrame(() => setEntranceReady(true)) }) }) @@ -1583,6 +1658,9 @@ export function MessageTimeline(props: { let element: HTMLDivElement const initialItem = virtualItemByKey().get(props.rowKey)! const initialRow = timelineRowByKey().get(props.rowKey)! + // Decided once at creation — remounts of an already-entered row get false. + // A number is the cascade's per-row animation-delay in ms (0 for live rows). + const enterDelay = shouldAnimateEnter(props.rowKey, initialRow) const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) const tool = () => { @@ -1609,6 +1687,15 @@ export function MessageTimeline(props: { onCleanup(() => { if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) + // NO exit ghost. The site's exit grammar (GONE: up + re-blur) was tried + // here as a positioned clone of the departing Thinking row and misfired + // in real use: a body-appended clone escapes the app's theme scope (its + // color var fell back to the dark-scheme yellow inside a light webview) + // and the rect captured at cleanup lags the virtualizer's relayout — + // a wrong-colored flash in the wrong place (Kate 2026-08-25). Removed + // rows are replaced instantly; the replacing block's entrance carries + // the transition. Exits in a virtualized timeline need real FLIP + // machinery or nothing — this is nothing, on purpose. }) return ( @@ -1622,11 +1709,12 @@ export function MessageTimeline(props: { height: `${item().size}px`, overflow: "clip", // Rounded virtual measurements can otherwise clip a framed row's outer paint. - // 4px, not 0.5px: the live rail dot breathes by a 0→4px ring - // (index.css thought-rail-breathe) and the old margin clipped the - // whole animation away — found in PR #246's testing. Keep in step - // with the ring size there. - "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "4px", + // 24px, not 0.5px: the live rail dot breathes by a 0→4px ring + // (index.css thought-rail-breathe; found clipped in PR #246's + // testing), an entering row rides the 8px --motion-enter-rise + // translate, and the entrance's blur(8px) paints a halo well past + // the border box — the margin must cover ring + rise + halo. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "24px", }} >
0 ? `${enterDelay}ms` : undefined, + }} > showReasoningSummaries: Accessor inlineComments: Accessor + /** the streaming prose tail has at least one settled chunk — computed by the + * timeline from the delta-accumulated text, which rows cannot see */ + tailProseSettled?: Accessor }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { @@ -38,6 +41,7 @@ export function createTimelineProjection(input: { input.status().type, input.inlineComments(), input.userMessages(), + input.tailProseSettled?.() ?? false, ), ) const activeMessageID = createMemo(() => projection().activeMessageID) diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index a5952a5e6..029264bce 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -50,12 +50,15 @@ describe("current session timeline rows", () => { ) expect(result.activeMessageID).toBe("msg_3") + // msg_4's reasoning is the busy turn's streaming tail (no time.end) — it + // is withheld until it completes (blocks land whole), so Thinking stands + // in as the working signal. expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_1", "assistant-part:msg_1:msg_2:text:0", "turn-gap:msg_3", "user-message:msg_3", - "assistant-part:msg_3:msg_4:reasoning:0", + "thinking:msg_3", ]) }) @@ -202,6 +205,9 @@ describe("current session timeline rows", () => { normalized.messages.filter((message) => message.role === "user"), ) - expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"]) + // The stale error row must not appear once the turn resumes. The resumed + // text is the streaming tail (no time.end) so it is withheld until it + // completes — Thinking, not the half-streamed part, is what renders. + expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "Thinking"]) }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 0a59e3cec..3b53671da 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -43,6 +43,9 @@ export namespace Timeline { status: SessionStatus["type"], inlineComments: boolean, projectedUserMessages: UserMessage[], + // the streaming prose tail has at least one settled chunk (computed by the + // timeline from the delta-accumulated text, which rows cannot see) + tailProseSettled = false, ) { const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] const turnByUserID = new Map() @@ -95,6 +98,7 @@ export namespace Timeline { status, turn.user.id === activeMessageID, inlineComments, + tailProseSettled, ), ), } @@ -110,6 +114,7 @@ export namespace Timeline { isActive: boolean, // v2 renders comments inside the user message attachments row instead of a strip row inlineComments: boolean, + tailProseSettled = false, ) { const rows: TimelineRow.TimelineRow[] = [] @@ -127,24 +132,46 @@ export namespace Timeline { .filter((part) => renderable(part, showReasoning)) .map((part) => ({ messageID: message.id, messageIndex, part })), ) + // Steps land as FULL blocks (Kate 2026-08-24: no typing reveal — the site + // demo's grammar). A streaming prose tail reveals in CHUNKS (Kate + // 2026-08-25: no waiting for the whole reply either): its row joins the + // timeline as soon as the FIRST chunk settles (tailProseSettled, computed + // by the timeline from the delta-accumulated text) and the renderer shows + // settled chunks only. Before that first chunk — and for streaming + // reasoning, which keeps whole-block withholding — the row is withheld + // and Thinking is the working signal. A part with a successor is complete + // by definition, so only the tail can ever be withheld, and a done turn + // (status not busy) withholds nothing. + const tail = assistantPartRefs.at(-1) + const tailStreaming = + isActive && + status === "busy" && + !error && + tail !== undefined && + tail.messageIndex === assistantMessages.length - 1 && + (tail.part.type === "text" || tail.part.type === "reasoning") && + !tail.part.time?.end + const tailWithheld = + tailStreaming && tail !== undefined && (tail.part.type === "reasoning" || !tailProseSettled) + const settledPartRefs = tailWithheld ? assistantPartRefs.slice(0, -1) : assistantPartRefs const assistantItems = interrupted && !compaction ? [ - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), { type: "interrupted" as const }, - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( + ...groupParts(settledPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( (group) => ({ type: "part" as const, group, }), ), ] - : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) + : groupParts(settledPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (comments.length > 0 && !inlineComments) @@ -182,14 +209,14 @@ export namespace Timeline { const turnIsRunning = isActive && status === "busy" && !error // The rail label names steps whose content doesn't already open with its // own title. Group rows announce themselves ("Explored", "Worked in - // shell", "Edited files") and tool cards wear their chips, so only bare - // prose and reasoning steps need a name here. + // shell", "Edited files") and tool cards wear their chips. Prose needs no + // caption either — the words ARE the step, and "Update" said nothing they + // don't (Kate 2026-08-24) — so only reasoning steps get a name here. const railLabel = (group: PartGroup): string | undefined => { if (group.type !== "part") return undefined const part = assistantPartRefs.find( (ref) => ref.messageID === group.ref.messageID && ref.part.id === group.ref.partID, )?.part - if (part?.type === "text") return "Update" if (part?.type === "reasoning") return "Reasoning" return undefined } @@ -217,7 +244,10 @@ export namespace Timeline { assistantGroupIndex += 1 }) - if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { + // In showReasoning mode the reasoning rows themselves carry the working + // signal — except while the tail is withheld (streaming), when Thinking + // must stand in for it or the turn would show nothing at all. + if (isActive && status === "busy" && !error && (showReasoning ? settledPartRefs.length === 0 || tailStreaming : true)) { const heading = assistantMessages .flatMap((message) => getMessageParts(message.id)) .map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined)) diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 553a08ba1..9cb9d15f7 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -28,9 +28,9 @@ export namespace TimelineRow { /** the turn is still working, so the tail step is in flight rather than done */ turnRunning: boolean /** eyebrow naming the action for steps whose content doesn't already open - * with its own title — assistant prose ("Update") and reasoning - * ("Reasoning"). Tool cards and the Explored / Worked-in-shell / Edited - * group headers announce themselves, so they carry no label here. */ + * with its own title — reasoning ("Reasoning") only. Prose carries no + * caption (the words are the step), and tool cards and the Explored / + * Worked-in-shell / Edited group headers announce themselves. */ railLabel?: string }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ diff --git a/packages/session-ui/src/components/message-part-text.ts b/packages/session-ui/src/components/message-part-text.ts index 3a8c4672d..e2a52a366 100644 --- a/packages/session-ui/src/components/message-part-text.ts +++ b/packages/session-ui/src/components/message-part-text.ts @@ -1,3 +1,74 @@ export function readPartText(accum: Record | undefined, part: { id: string; text?: string }): string { return (accum?.[part.id] ?? part.text ?? "").trim() } + +/* Streaming prose lands in whole CHUNKS (Kate 2026-08-25: no typing reveal, + but no waiting for the entire reply either). A chunk boundary is a blank + line that is + · OUTSIDE a code fence — splitting inside one corrupts the markdown, and + · not immediately before a list item, blockquote, or indented line — + splitting those restarts ordered-list numbering and swells the gap + between fragments that render as separate lists. + Text past the last boundary is still being composed and stays withheld + until the next boundary or completion. */ + +const FENCE = /^\s{0,3}(```|~~~)/ +const CONTINUATION = /^\s{0,3}([-*+]\s|\d{1,3}[.)]\s|>)|^\s{4,}\S/ + +/** Every settled chunk boundary of a streaming text, in order. Each value is + * the char index where the NEXT chunk begins. Monotonic as the text grows. + * A boundary is invalid when the next line CONTINUES a construct the + * previous line already started (list item after list item, quote after + * quote): splitting there restarts ordered-list numbering across fragments. + * A construct's FIRST line after a paragraph is a fine place to split. */ +function chunkBoundaries(text: string): number[] { + const lines = text.split("\n") + const boundaries: number[] = [] + let inFence = false + let offset = 0 + let prevNonBlank: string | undefined + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (FENCE.test(line)) inFence = !inFence + offset += line.length + 1 + if (line.trim() !== "") prevNonBlank = line + if (inFence || line.trim() !== "") continue + // blank line: the boundary candidate sits at the next non-blank line + let j = i + 1 + let candidate = offset + while (j < lines.length && lines[j].trim() === "") { + candidate += lines[j].length + 1 + j++ + } + if (j >= lines.length) break // trailing blanks — the tail is still composing + if ( + CONTINUATION.test(lines[j]) && + prevNonBlank !== undefined && + CONTINUATION.test(prevNonBlank) + ) + continue + boundaries.push(candidate) + offset = candidate + i = j - 1 + } + return boundaries +} + +/** Index just past the last settled chunk boundary (0 if nothing settled). */ +export function settledChunkBoundary(text: string): number { + const boundaries = chunkBoundaries(text) + return boundaries.length > 0 ? boundaries[boundaries.length - 1] : 0 +} + +/** The settled prefix split into renderable chunks, plus the withheld tail. */ +export function splitSettledChunks(text: string): { chunks: string[]; tail: string } { + const boundaries = chunkBoundaries(text) + const chunks: string[] = [] + let start = 0 + for (const boundary of boundaries) { + const chunk = text.slice(start, boundary) + if (chunk.trim() !== "") chunks.push(chunk) + start = boundary + } + return { chunks, tail: text.slice(start) } +} diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index c8de7d25b..01da11058 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -148,16 +148,19 @@ /* The prompt bubble, from the website's /amicode chat (harmoniqs-ai app/components/demo/parts.jsx, `Turn`): the user's words are the INVERSE - of the ground — bg-deep/text-bg there. bg-inverse/text-inverse is that - same pair in both schemes: an ink bubble on light, a near-white bubble on - dark. Padding is the site's px-4 py-2.5. */ + of the ground — bg-deep/text-bg there. The app tokens + (--prompt-bubble-*, design-polish.css) realise that per scheme: the + site's ink bubble verbatim on light; on dark a quiet elevated layer + under base ink — any light bubble glared at chat scale on a dark field. + Fallback = raw inverse, for consumers without the app skin. + Padding is the site's px-4 py-2.5. */ [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--v2-background-bg-inverse); - color: var(--v2-text-text-inverse); + background: var(--prompt-bubble-bg, var(--v2-background-bg-inverse)); + color: var(--prompt-bubble-ink, var(--v2-text-text-inverse)); border: none; padding: 10px 16px; border-radius: var(--radius-lg); @@ -172,7 +175,7 @@ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.85em; color: inherit; - background: color-mix(in srgb, var(--v2-text-text-inverse) 14%, transparent); + background: color-mix(in srgb, var(--prompt-bubble-ink, var(--v2-text-text-inverse)) 14%, transparent); border-radius: var(--radius-sm); padding: 0.08em 0.35em; } diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 25dcbae6b..651d860ae 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { readPartText } from "./message-part-text" +import { readPartText, settledChunkBoundary, splitSettledChunks } from "./message-part-text" describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { @@ -26,3 +26,45 @@ describe("readPartText", () => { expect(readPartText(undefined, { id: "part_1", text: "\n body \n" })).toBe("body") }) }) + +describe("splitSettledChunks", () => { + + test("no boundary while a single paragraph streams", () => { + const text = "The first paragraph is still being" + expect(settledChunkBoundary(text)).toBe(0) + expect(splitSettledChunks(text)).toEqual({ chunks: [], tail: text }) + }) + + test("a paragraph settles once the next one has started", () => { + const text = "First paragraph.\n\nSecond is being writ" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks).toEqual(["First paragraph.\n\n"]) + expect(tail).toBe("Second is being writ") + }) + + test("multiple settled paragraphs split at every boundary", () => { + const text = "One.\n\nTwo.\n\nThree is being writ" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks).toEqual(["One.\n\n", "Two.\n\n"]) + expect(tail).toBe("Three is being writ") + }) + + test("a blank line inside a code fence is not a boundary", () => { + const text = "Intro.\n\n```py\na = 1\n\nb = 2\n```\n\nAfter the fence starts" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks).toEqual(["Intro.\n\n", "```py\na = 1\n\nb = 2\n```\n\n"]) + expect(tail).toBe("After the fence starts") + }) + + test("splits before a list's first item, never between its items", () => { + const text = "Steps:\n\n1. one\n\n2. two\n\n> quoted\n\nNext paragraph beg" + const { chunks, tail } = splitSettledChunks(text) + expect(chunks).toEqual(["Steps:\n\n", "1. one\n\n2. two\n\n> quoted\n\n"]) + expect(tail).toBe("Next paragraph beg") + }) + + test("trailing blank lines do not settle the tail", () => { + const text = "Done paragraph.\n\n" + expect(settledChunkBoundary(text)).toBe(0) + }) +}) diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 7ba45ce6a..505ee43dd 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -16,6 +16,7 @@ import { Switch, onCleanup, Index, + untrack, type JSX, type ComponentProps, } from "solid-js" @@ -72,7 +73,7 @@ import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" import { animate } from "motion" import { attached, inline, kind, typeLabel } from "./message-file" -import { readPartText } from "./message-part-text" +import { readPartText, splitSettledChunks } from "./message-part-text" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" const reducedMotion = () => @@ -342,6 +343,46 @@ function createPacedValue(getValue: () => string, live?: () => boolean) { return value } +// Streaming prose lands in whole CHUNKS (Kate 2026-08-25): each settled chunk +// (message-part-text.ts boundaries — blank lines outside fences, never inside +// a list) mounts once and plays the entrance the host skins onto +// [data-part-enter]; the still-composing tail stays withheld — the working +// indicator is the signal while it grows. On completion the remainder lands +// as the final chunk, with its entrance. +// +// The once-only ledger is PART-scoped, not component-scoped: the part +// component can remount mid-stream or at completion (observed live — a +// remount at time.end reset a component-local count and swallowed the final +// chunk's entrance), so the revealed count survives in a module map. A chunk +// animates only when its index is past what this part had already revealed +// at mount; scroll-back remounts of finished parts reveal nothing new. +const revealedChunks = new Map() + +function ChunkedStreamMarkdown(props: { text: string; done: boolean; cacheKey: string }) { + const parts = createMemo(() => { + const { chunks, tail } = splitSettledChunks(props.text) + if (props.done && tail.trim() !== "") return [...chunks, tail] + return chunks + }) + // A part that mounts already done with no ledger entry is HISTORY — every + // chunk counts as revealed, nothing animates. Only parts observed streaming + // (ledgered) animate their later chunks. + const already = revealedChunks.get(props.cacheKey) ?? (untrack(() => props.done) ? Number.MAX_SAFE_INTEGER : 0) + createEffect(() => { + const count = parts().length + if (count > (revealedChunks.get(props.cacheKey) ?? 0)) revealedChunks.set(props.cacheKey, count) + }) + return ( + + {(chunk, index) => ( +
= already ? "" : undefined}> + +
+ )} +
+ ) +} + function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boolean }) { const value = createPacedValue( () => props.text, @@ -2136,9 +2177,14 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- }> - - + {/* Prose ALWAYS renders as chunks — each fragment Amico relays is + its own bordered card, a message within the chat (Kate + 2026-08-25), and history must split identically so the cards + are consistent across reloads. While streaming, only settled + chunks show (no typing reveal, no waiting for the whole reply); + each new chunk mounts once with its entrance, ledgered per part + so remounts never re-animate. */} +