Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 2 additions & 3 deletions packages/session-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -916,9 +916,8 @@ export function AssistantParts(props: {
</Index>
{/* amicode: Amico's working presence — the offset accent lane. Shown only
while an in-domain turn streams (state === "on"), decoupled from any
card-suppression: the thinking block runs (H-mark + cycling gerund +
live elapsed/tokens — it owns its mark now, no lane-head here; two
signature marks side by side compete with each other). Pops out the
card-suppression: the thinking block (wave + cycling gerund + live
elapsed/tokens) runs without a duplicate mark. Pops out the
moment working flips false. spec-20260712-amico-third-actor. */}
<Show when={inDomainTurn() && props.working && last() === grouped().at(-1)?.key}>
<div class="amc-lane" data-slot="amico-working">
Expand Down
3 changes: 1 addition & 2 deletions packages/ui/src/amicode/amico-presence.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@ function MockFlow(props: { state: PresenceState }) {
{/* Amico lane */}
<Show when={props.state === "stepping_in" || props.state === "on"}>
<div class="amc-lane" classList={{ "is-stepping-in": props.state === "stepping_in" }}>
{/* stepping_in gets a bare lane-head; once working, the thinking block
OWNS its H-mark (thinking-line.tsx) so no lane-head here */}
{/* stepping_in gets a bare lane-head; the thinking block (wave+verb) owns the working indicator now */}
<Show when={props.state === "stepping_in"}>
<div class="amc-lane-head"><AmicoMark running />stepping in…</div>
</Show>
Expand Down
10 changes: 4 additions & 6 deletions packages/ui/src/amicode/amico-wave.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
// mapping from its source file, so it tracks theme changes instead of a hand-copied hex.
import { createSignal, For, onMount } from "solid-js"
import { AmicoWave } from "./amico-wave"
import { AmicoMark } from "./spinner"
import { MODE_WAVELENGTHS, WAVE_BOX } from "./wave-geometry"
import oc2ThemeJson from "../theme/themes/oc-2.json"
import { resolveThemeVariantV2 } from "../theme/v2/resolve"
Expand Down Expand Up @@ -111,14 +110,13 @@ function SchemePane(props: { scheme: "light" | "dark"; children: unknown }) {
)
}

// Mimics the real mount site (thinking-line.tsx): H-mark, glyph and bold gerund
// on the top row; the muted meta below, starting under the glyph — reusing the
// actual .amc-thinking* classes from amicode.css. DOM order is word-first so
// the block's first baseline is the verb's (see thinking-line.tsx).
// Mimics the real mount site (thinking-line.tsx): glyph and bold gerund on the
// top row; the muted meta below, starting under the glyph — reusing the actual
// .amc-thinking* classes from amicode.css. DOM order is word-first so the
// block's first baseline is the verb's (see thinking-line.tsx).
const ThinkingRow = () => (
<span class="amc-thinking">
<span class="amc-thinking-word">Percolating…</span>
<AmicoMark />
<AmicoWave />
<span class="amc-thinking-meta">5m 13s · ↑ 2.4k tokens</span>
</span>
Expand Down
21 changes: 10 additions & 11 deletions packages/ui/src/amicode/amicode.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,24 +53,24 @@
}

/* ---- thinking block (Claude-Code-esque working indicator) ---------------- */
/* Two-row grid shown while a reply streams (thinking-line.tsx): the H-mark,
* harmonic wave and cycling gerund ride the top row; the live meta line
* (elapsed · tokens · esc) sits below, starting under the wave. Anti-glitch
* invariants, earned the hard way:
/* Two-row grid shown while a reply streams (thinking-line.tsx): the harmonic
* wave and cycling gerund ride the top row; the live meta line (elapsed ·
* tokens · esc) sits below, starting under the wave. Anti-glitch invariants,
* earned the hard way:
* - GRID, never flex-wrap — the old flex row once let a greedy sibling
* heading squeeze the meta until "↑ 8.7k tokens" shattered across three
* lines, and baseline alignment scattered the mark/wave/verb;
* lines, and baseline alignment scattered the wave/verb;
* - the verb reserves the longest gerund's width (16ch covers
* "Fidelitymaxxing…") so word rotation never shifts the layout around it;
* - the meta renders from mount (elapsed always ticks), so the block's height
* is constant whether or not tokens/hints are present;
* - nowrap + ellipsis on the meta for absurdly narrow panes.
* DOM order is word-first so the block's first baseline is the VERB's — the
* sibling activity heading (session-turn-thinking) aligns with the verb, not
* the mark's bottom edge. Explicit grid placement keeps the visual order. */
* the wave's edge. Explicit grid placement keeps the visual order. */
.amc-thinking {
display: inline-grid;
grid-template-columns: auto auto 1fr;
grid-template-columns: auto 1fr;
column-gap: 8px;
row-gap: 2px;
align-items: center;
Expand All @@ -80,18 +80,17 @@
letter-spacing: 0.01em;
color: var(--v2-text-text-muted);
}
.amc-thinking .amc-mark { grid-column: 1; grid-row: 1; }
.amc-thinking .amc-wave { grid-column: 2; grid-row: 1; }
.amc-thinking .amc-wave { grid-column: 1; grid-row: 1; }
.amc-thinking-word {
grid-column: 3;
grid-column: 2;
grid-row: 1;
font-weight: 600;
white-space: nowrap;
min-width: 16ch;
color: var(--v2-text-text-accent);
}
.amc-thinking-meta {
grid-column: 2 / -1;
grid-column: 1 / -1;
grid-row: 2;
min-width: 0;
overflow: hidden;
Expand Down
10 changes: 5 additions & 5 deletions packages/ui/src/amicode/thinking-line.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ export default {
component: `### Thinking block

The Claude-Code-esque "working" indicator shown while a reply streams. A
two-row grid block that OWNS its H-mark: the mark, harmonic wave glyph and
cycling gerund ride the top row; the live meta line (elapsed · tokens · esc)
sits below, starting under the wave. The verb reserves the longest gerund's
width so word rotation never shifts the layout, and the meta can never wrap
mid-phrase.
two-row grid block: the harmonic wave glyph and cycling gerund ride the top
row; the live meta line (elapsed · tokens · esc) sits below, starting under
the wave. The verb reserves the longest gerund's width so word rotation never
shifts the layout, and the meta can never wrap mid-phrase. Verbs are shuffled
per mount so each turn opens on a different word.

Under \`prefers-reduced-motion\` the wave and word are static; the elapsed
counter still advances (it's information, not decoration).`,
Expand Down
22 changes: 9 additions & 13 deletions packages/ui/src/amicode/thinking-line.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { createSignal, onCleanup, onMount, Show, type ComponentProps } from "solid-js"
import { wordAt, formatElapsed, formatTokens } from "./thinking"
import { shuffledWordAt, SHUFFLED_WORDS, formatElapsed, formatTokens } from "./thinking"
import { AmicoWave } from "./amico-wave"
import { AmicoMark } from "./spinner"

// AMICODE: the "thinking" working indicator — a two-row block shown while a
// reply streams (app TimelineThinkingRow; session-ui lane in message-part.tsx).
// Row 1: H-mark + harmonic wave (amico-wave.tsx) + the cycling gerund. Row 2:
// the live meta line (elapsed · tokens · esc), starting under the wave. The
// block OWNS the mark — mount sites no longer add their own (a `mark={false}`
// opt-out remains for contexts that carry their own signature). Pure bits
// (word rotation, label formatting) live in ./thinking for testing.
// Row 1: harmonic wave (amico-wave.tsx) + the cycling gerund. Row 2:
// the live meta line (elapsed · tokens · esc), starting under the wave.
// Pure bits (word rotation, label formatting) live in ./thinking for testing.
//
// Layout invariants (earned the hard way — the CSS side lives in amicode.css):
// - the meta can NEVER wrap mid-phrase — the old flex row once let a greedy
Expand Down Expand Up @@ -47,14 +44,16 @@ export function ThinkingLine(props: {
tokens?: number
/** show the "esc to interrupt" hint — only pass when the mount site wires esc */
interruptible?: boolean
/** render the H-mark as the block's anchor (default true) */
/** @deprecated no-op — the H-mark was removed (wave+verb is the indicator now); kept for compat */
mark?: boolean
class?: string
style?: ComponentProps<"span">["style"]
}) {
const still = reducedMotion()
const [elapsedMs, setElapsedMs] = createSignal(0)
const [tick, setTick] = createSignal(0)
// Random start so each turn/mount opens on a different verb; the module-level
// shuffle already decorrelates reloads, this decorrelates simultaneous mounts.
const [tick, setTick] = createSignal(Math.floor(Math.random() * SHUFFLED_WORDS.length))

onMount(() => {
const start = Date.now()
Expand All @@ -66,7 +65,7 @@ export function ThinkingLine(props: {
}
})

const word = () => (still ? "Thinking" : wordAt(tick()))
const word = () => (still ? "Thinking" : shuffledWordAt(tick()))

return (
<span
Expand All @@ -81,9 +80,6 @@ export function ThinkingLine(props: {
<span class="amc-thinking-word" aria-hidden="true">
{word()}…
</span>
<Show when={props.mark ?? true}>
<AmicoMark />
</Show>
<AmicoWave />
<span class="amc-thinking-meta" aria-hidden="true">
<span class="amc-thinking-elapsed">{formatElapsed(elapsedMs())}</span>
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/amicode/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ export function wordAt(tick: number): string {
return THINKING_WORDS[i]
}

/** Fisher-Yates shuffle — returns a new array, never mutates the input. */
function shuffledCopy<T>(arr: readonly T[]): T[] {
const out = [...arr]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
const tmp = out[i]!
out[i] = out[j]!
out[j] = tmp
}
return out
}

/** Shuffled order — computed once at module load so a single turn cycles without repeats, but order varies across reloads. */
export const SHUFFLED_WORDS: readonly string[] = shuffledCopy(THINKING_WORDS)

/** Like wordAt but over the shuffled permutation — this is what the live thinker uses. */
export function shuffledWordAt(tick: number): string {
const n = SHUFFLED_WORDS.length
const i = ((Math.floor(tick) % n) + n) % n
return SHUFFLED_WORDS[i]!
}

/** Compact elapsed label: 0s, 12s, 1m 03s, 12m 05s. */
export function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000))
Expand Down
Loading