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. */}
+