Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7b1ff4e
fix(chat): seat the dark prompt bubble at ~85% luminance — no more glare
kateebonner Aug 24, 2026
2bfaeee
fix(timeline): drop the UPDATE eyebrow on prose steps — it said nothing
kateebonner Aug 24, 2026
befe048
fix(app): centre the draft landing's mark+composer optically
kateebonner Aug 24, 2026
1ef7c41
fix(chat): seat the dark prompt bubble lower still — ~70% luminance
kateebonner Aug 25, 2026
0d4359c
feat(timeline): blocks land whole, with a crisp entrance — no typing …
kateebonner Aug 25, 2026
cab3466
Merge branch 'kate/landing-optical-center' into kate/website-chat-style
kateebonner Aug 25, 2026
69b3aad
Merge branch 'kate/timeline-enter-animation' into kate/website-chat-s…
kateebonner Aug 25, 2026
d278c3a
fix(timeline): the active turn animates even when present at first mount
kateebonner Aug 25, 2026
4633ade
fix(timeline): judge the live turn from data, and let the entrance read
kateebonner Aug 25, 2026
eff481e
fix(chat): the dark bubble goes quiet — layer-02 under base ink
kateebonner Aug 25, 2026
5ede1a9
feat(timeline): the open cascade — and only two moments ever animate
kateebonner Aug 25, 2026
451b537
feat(timeline): the entrance is the website's, verbatim — blur, curve…
kateebonner Aug 25, 2026
7a71726
feat(chat): streaming replies land in chunks — no waiting for the who…
kateebonner Aug 25, 2026
acc59c6
feat(chat): the working indicator is hydrogen's harmonics — circular,…
kateebonner Aug 25, 2026
b8bae71
feat(chat): the working indicator is one soft pulse — clean, conceptless
kateebonner Aug 25, 2026
6e2ad00
fix(chat): the pulse ripple goes crisp — the site's rail-dot verbatim
kateebonner Aug 25, 2026
73efa1f
fix(timeline): remove the exit ghost — it misfired in the light webview
kateebonner Aug 25, 2026
08153ff
revert(chat): the working indicator returns to the wave — loader cut …
kateebonner Aug 25, 2026
ad4b296
feat(chat): each reply is one bordered segment — the site's Panel gra…
kateebonner Aug 25, 2026
cc76b12
fix(chat): every fragment is its OWN card — messages within the chat
kateebonner Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/app/src/design-polish.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}

/* ============================================================
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/pages/new-session/new-session-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
{/* 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. */}
<div class="absolute inset-0 flex items-center justify-center px-6 pb-24">
<div class={NEW_SESSION_CONTENT_WIDTH}>
{/* amicode: brand mark + wordmark (recovered composition — the
H-robot over the AMICODE wordmark, Kate's Kimi-clean ordering;
Expand Down
123 changes: 109 additions & 14 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<HTMLDivElement>()
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<string>()
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
Expand Down Expand Up @@ -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))
})
})

Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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 (
Expand All @@ -1622,19 +1709,24 @@ 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",
}}
>
<div
ref={(value) => {
element = value
}}
data-index={item().index}
style={{ "min-height": ready() ? undefined : `${initialItem.size}px` }}
data-timeline-enter={enterDelay !== false ? "" : undefined}
style={{
"min-height": ready() ? undefined : `${initialItem.size}px`,
"animation-delay": enterDelay !== false && enterDelay > 0 ? `${enterDelay}ms` : undefined,
}}
>
<TimelineRowView
row={row()}
Expand Down Expand Up @@ -1716,6 +1808,7 @@ export function MessageTimeline(props: {
onScroll={handleListScroll}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
data-entrance-pending={entranceReady() ? undefined : ""}
style={{
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
opacity: scrollReady() ? undefined : "0",
Expand Down Expand Up @@ -2287,10 +2380,11 @@ export function MessageTimeline(props: {
class="ml-auto block w-fit max-w-[min(75%,56ch)] text-left cursor-pointer border-none rounded-lg px-3 py-1.5 text-[13px] leading-[18px] font-normal truncate backdrop-blur-[2px]"
style={{
// the ghost of the prompt bubble keeps the bubble's own
// inverse ground, translucent — one grammar for the
// user's words on every surface
background: "color-mix(in srgb, var(--v2-background-bg-inverse) 90%, transparent)",
color: "var(--v2-text-text-inverse)",
// ground, translucent — one grammar for the user's
// words on every surface (--prompt-bubble-*: the
// inverse, seated per scheme in design-polish.css)
background: "color-mix(in srgb, var(--prompt-bubble-bg) 90%, transparent)",
color: "var(--prompt-bubble-ink)",
"box-shadow": "0 1px 3px color-mix(in srgb, var(--v2-background-bg-base) 40%, transparent)",
}}
onClick={scrollToBubbleMessage}
Expand Down Expand Up @@ -2319,10 +2413,11 @@ export function MessageTimeline(props: {
class="ml-auto block w-fit max-w-[min(75%,56ch)] text-left cursor-pointer border-none rounded-lg px-3 py-1.5 text-[13px] leading-[18px] font-normal truncate backdrop-blur-[2px]"
style={{
// the ghost of the prompt bubble keeps the bubble's own
// inverse ground, translucent — one grammar for the
// user's words on every surface
background: "color-mix(in srgb, var(--v2-background-bg-inverse) 90%, transparent)",
color: "var(--v2-text-text-inverse)",
// ground, translucent — one grammar for the user's
// words on every surface (--prompt-bubble-*: the
// inverse, seated per scheme in design-polish.css)
background: "color-mix(in srgb, var(--prompt-bubble-bg) 90%, transparent)",
color: "var(--prompt-bubble-ink)",
"box-shadow": "0 1px 3px color-mix(in srgb, var(--v2-background-bg-base) 40%, transparent)",
}}
onClick={scrollToBubbleMessage}
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/pages/session/timeline/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export function createTimelineProjection(input: {
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
/** 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<boolean>
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
Expand All @@ -38,6 +41,7 @@ export function createTimelineProjection(input: {
input.status().type,
input.inlineComments(),
input.userMessages(),
input.tailProseSettled?.() ?? false,
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
Expand Down
Loading
Loading