From cb4a8911c93e702a24494401010890ce7fbdbf58 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 11:34:39 -0400 Subject: [PATCH 01/13] feat(amicode): pure geometry + timing for the harmonic wave indicator (cherry picked from commit f0864330cf96f3eb5a0f9e96ea08615ad7997cd0) --- packages/ui/src/amicode/amico-wave.test.ts | 89 ++++++++++++++++++ packages/ui/src/amicode/amico-wave.ts | 102 +++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 packages/ui/src/amicode/amico-wave.test.ts create mode 100644 packages/ui/src/amicode/amico-wave.ts diff --git a/packages/ui/src/amicode/amico-wave.test.ts b/packages/ui/src/amicode/amico-wave.test.ts new file mode 100644 index 0000000000..0f8305b2a4 --- /dev/null +++ b/packages/ui/src/amicode/amico-wave.test.ts @@ -0,0 +1,89 @@ +// packages/ui/src/amicode/amico-wave.test.ts +import { describe, expect, test } from "bun:test" +import { + WAVE_BOX, + WAVE_AMP, + WAVE_LEAD_STROKE, + WAVE_PERIOD_MS, + MODE_HOLD_MS, + MODE_WAVELENGTHS, + MODE_PATHS, + companionDelayMs, + modeCadenceMs, + modeDelaysMs, + visibleModesAt, + samplePoints, +} from "./amico-wave" + +describe("quadrature", () => { + test("companion delay is exactly a quarter period, derived from the period", () => { + expect(companionDelayMs()).toBe(-WAVE_PERIOD_MS / 4) + expect(companionDelayMs(2000)).toBe(-500) + }) +}) + +describe("harmonic climb", () => { + test("cadence is hold x mode count", () => { + expect(modeCadenceMs()).toBe(MODE_HOLD_MS * MODE_WAVELENGTHS.length) + expect(modeCadenceMs(1000, 4)).toBe(4000) + }) + + test("delays DESCEND in magnitude so the visible sequence ASCENDS", () => { + // Regression: -1*step / -2*step silently plays 1 -> 3 -> 2. + expect(modeDelaysMs()).toEqual([0, -4600, -2300]) + }) + + test("exactly one mode is visible at any instant, and the order is 1,2,3", () => { + const cadence = modeCadenceMs() + const seen: number[] = [] + // sample the middle of each hold window, avoiding exact boundaries + for (let t = MODE_HOLD_MS / 2; t < cadence; t += MODE_HOLD_MS) { + const vis = visibleModesAt(t) + expect(vis).toHaveLength(1) + seen.push(vis[0]) + } + expect(seen).toEqual([0, 1, 2]) + }) + + test("never zero or two modes visible across a dense sweep of two cadences", () => { + const cadence = modeCadenceMs() + for (let t = 0; t < cadence * 2; t += 37) { + expect(visibleModesAt(t)).toHaveLength(1) + } + }) +}) + +describe("geometry", () => { + test("one path per mode, mode n has n full wavelengths across the box", () => { + expect(MODE_PATHS).toHaveLength(MODE_WAVELENGTHS.length) + MODE_WAVELENGTHS.forEach((lambda, i) => { + expect(WAVE_BOX.w / lambda).toBe(i + 1) + }) + }) + + test("every sampled point stays inside the box once stroke width is accounted for", () => { + const half = WAVE_LEAD_STROKE / 2 + for (const lambda of MODE_WAVELENGTHS) { + for (const [x, y] of samplePoints(lambda)) { + expect(x).toBeGreaterThanOrEqual(0) + expect(x).toBeLessThanOrEqual(WAVE_BOX.w) + expect(y - half).toBeGreaterThanOrEqual(0) + expect(y + half).toBeLessThanOrEqual(WAVE_BOX.h) + } + } + }) + + test("amplitude is actually used — extremes reach within 0.1px of the design bound", () => { + const ys = samplePoints(MODE_WAVELENGTHS[0]).map(([, y]) => y) + expect(Math.min(...ys)).toBeCloseTo(WAVE_BOX.mid - WAVE_AMP, 1) + expect(Math.max(...ys)).toBeCloseTo(WAVE_BOX.mid + WAVE_AMP, 1) + }) + + test("paths are well-formed and contain no SVG ids", () => { + for (const d of MODE_PATHS) { + expect(d.startsWith("M")).toBe(true) + expect(d).not.toContain("url(") + expect(d).not.toContain("id=") + } + }) +}) diff --git a/packages/ui/src/amicode/amico-wave.ts b/packages/ui/src/amicode/amico-wave.ts new file mode 100644 index 0000000000..04739b9151 --- /dev/null +++ b/packages/ui/src/amicode/amico-wave.ts @@ -0,0 +1,102 @@ +// packages/ui/src/amicode/amico-wave.ts +// AMICODE: pure geometry + timing for the harmonic working indicator (amico-wave.tsx). +// Spec: spec-20260728-104232-amicode-working-indicator-harmonic-wave. +// +// Fork convention (see thinking.ts / run-series.ts): keep the maths DOM-free and +// unit-tested, keep the component thin. +// +// Every locked constant lives HERE and nowhere else. In particular the per-mode +// animation-delay values are computed by modeDelaysMs() and handed to the component as +// inline style — deliberately NOT written into amicode.css. A hand-authored -1x/-2x +// ordering silently plays the climb 1 -> 3 -> 2, and it is invisible on inspection. +// Keeping the delays in tested code makes that unrepresentable. + +/** Box is 1:1 with device pixels; the viewBox aspect MUST match the rendered size or the + * default preserveAspectRatio letterboxes the wave and silently shrinks the amplitude. */ +export const WAVE_BOX = { w: 30, h: 12, mid: 6 } as const + +/** ±4.3px puts the extremes at 0.95/11.05px including the lead stroke — ~0.95px of margin. + * ±4.9 leaves ~0.3px and clips visibly on subpixel layouts. */ +export const WAVE_AMP = 4.3 + +export const WAVE_LEAD_STROKE = 1.5 +export const WAVE_COMPANION_STROKE = 1.2 +export const WAVE_COMPANION_OPACITY = 0.4 + +export const WAVE_PERIOD_MS = 1150 +/** Dwells at the extremes and rips through the zero crossing, so the residual flat instant + * is ~1/3 as long as under ease-in-out. */ +export const WAVE_EASING = "cubic-bezier(.9,0,.1,1)" + +export const MODE_HOLD_MS = 2300 +/** Modes 1, 2, 3 across the 30px box. */ +export const MODE_WAVELENGTHS = [30, 15, 10] as const + +const SAMPLE_STEP = 0.6 + +/** Quadrature: the companion is a quarter period behind. Derived from the period so the two + * numbers can never drift apart. */ +export function companionDelayMs(periodMs: number = WAVE_PERIOD_MS): number { + return -periodMs / 4 +} + +export function modeCadenceMs( + holdMs: number = MODE_HOLD_MS, + modeCount: number = MODE_WAVELENGTHS.length, +): number { + return holdMs * modeCount +} + +/** + * Negative animation-delays that make the visible mode sequence ASCEND 1 -> 2 -> 3. + * + * A delay of -|d| starts the element |d| into its cycle, so it is visible over + * t ∈ [cadence - |d|, cadence - |d| + hold). + * Wanting mode i visible over [i*hold, (i+1)*hold) gives |d_i| = cadence - i*hold — + * i.e. the magnitudes DESCEND as the index ascends. Mode 0 normalises to 0. + */ +export function modeDelaysMs( + holdMs: number = MODE_HOLD_MS, + modeCount: number = MODE_WAVELENGTHS.length, +): number[] { + const cadence = modeCadenceMs(holdMs, modeCount) + return Array.from({ length: modeCount }, (_, i) => { + const d = -(cadence - i * holdMs) + return d === -cadence ? 0 : d + }) +} + +/** Which mode indices are visible at time t. The oracle for the ordering test — derived from + * modeDelaysMs so it genuinely exercises the delay maths rather than restating the answer. */ +export function visibleModesAt( + tMs: number, + holdMs: number = MODE_HOLD_MS, + modeCount: number = MODE_WAVELENGTHS.length, +): number[] { + const cadence = modeCadenceMs(holdMs, modeCount) + const delays = modeDelaysMs(holdMs, modeCount) + const out: number[] = [] + delays.forEach((d, i) => { + const local = (((tMs - d) % cadence) + cadence) % cadence + if (local < holdMs) out.push(i) + }) + return out +} + +/** Sampled points of one standing mode, y measured downward from the top of the box. */ +export function samplePoints(wavelength: number): Array<[number, number]> { + const pts: Array<[number, number]> = [] + const round = (n: number) => Math.round(n * 100) / 100 + for (let x = 0; x <= WAVE_BOX.w + 1e-9; x += SAMPLE_STEP) { + pts.push([round(x), round(WAVE_BOX.mid - WAVE_AMP * Math.sin((2 * Math.PI * x) / wavelength))]) + } + return pts +} + +/** Open polyline path for one mode. */ +export function modePath(wavelength: number): string { + return "M" + samplePoints(wavelength).map(([x, y]) => `${x},${y}`).join("L") +} + +/** Computed once at module load — 153 Math.sin calls total, nowhere near a frame path. */ +export const MODE_PATHS: readonly string[] = MODE_WAVELENGTHS.map(modePath) From 60f7c924690250b4949c80c237bec56f95a40160 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 11:49:01 -0400 Subject: [PATCH 02/13] test(amicode): close two mutation-proven gaps in the wave geometry tests (cherry picked from commit 358d2aebf85a2c70cdad81a1d2b7ba26c084e94a) --- packages/ui/src/amicode/amico-wave.test.ts | 39 ++++++++++++++++++++-- packages/ui/src/amicode/amico-wave.ts | 19 ++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/amicode/amico-wave.test.ts b/packages/ui/src/amicode/amico-wave.test.ts index 0f8305b2a4..fbca72cd11 100644 --- a/packages/ui/src/amicode/amico-wave.test.ts +++ b/packages/ui/src/amicode/amico-wave.test.ts @@ -8,6 +8,8 @@ import { MODE_HOLD_MS, MODE_WAVELENGTHS, MODE_PATHS, + MODE_VISIBLE_FRACTION, + MODE_VISIBLE_PCT, companionDelayMs, modeCadenceMs, modeDelaysMs, @@ -51,9 +53,24 @@ describe("harmonic climb", () => { expect(visibleModesAt(t)).toHaveLength(1) } }) + + test("window boundaries tile exactly — one mode at every seam", () => { + for (const t of [0, 2300, 4600, 6900]) { + expect(visibleModesAt(t)).toHaveLength(1) + } + }) + + test("the keyframe fraction matches the hold/cadence ratio exactly", () => { + expect(MODE_VISIBLE_FRACTION).toBeCloseTo(MODE_HOLD_MS / modeCadenceMs(), 10) + expect(MODE_VISIBLE_PCT).toBe("33.3333%") + }) }) describe("geometry", () => { + test("the axis is centred in the box", () => { + expect(WAVE_BOX.h / 2).toBe(WAVE_BOX.mid) + }) + test("one path per mode, mode n has n full wavelengths across the box", () => { expect(MODE_PATHS).toHaveLength(MODE_WAVELENGTHS.length) MODE_WAVELENGTHS.forEach((lambda, i) => { @@ -79,11 +96,27 @@ describe("geometry", () => { expect(Math.max(...ys)).toBeCloseTo(WAVE_BOX.mid + WAVE_AMP, 1) }) - test("paths are well-formed and contain no SVG ids", () => { + test("the ~0.95px stroke margin survives — not just box containment", () => { + const ys = samplePoints(MODE_WAVELENGTHS[0]).map(([, y]) => y) + const margin = Math.min(...ys) - WAVE_LEAD_STROKE / 2 + expect(margin).toBeGreaterThanOrEqual(0.9) + }) + + test("every mode has a node at both ends of the box", () => { + for (const lambda of MODE_WAVELENGTHS) { + const pts = samplePoints(lambda) + expect(pts[0]).toEqual([0, WAVE_BOX.mid]) + expect(pts.at(-1)).toEqual([WAVE_BOX.w, WAVE_BOX.mid]) + } + }) + + // Path data's character set is only `,.0123456789LM`, so an id/url() assertion here can + // never fail — it would only prove the test file typo-checks itself. The real id-collision + // risk (several wave instances mounting at once, SVG ids being document-global) lives at + // the component layer (Task 2), not in this pure geometry, so it is not covered here. + test("paths are well-formed", () => { for (const d of MODE_PATHS) { expect(d.startsWith("M")).toBe(true) - expect(d).not.toContain("url(") - expect(d).not.toContain("id=") } }) }) diff --git a/packages/ui/src/amicode/amico-wave.ts b/packages/ui/src/amicode/amico-wave.ts index 04739b9151..88cbeefeb2 100644 --- a/packages/ui/src/amicode/amico-wave.ts +++ b/packages/ui/src/amicode/amico-wave.ts @@ -32,10 +32,15 @@ export const MODE_HOLD_MS = 2300 /** Modes 1, 2, 3 across the 30px box. */ export const MODE_WAVELENGTHS = [30, 15, 10] as const +/** Fraction of the cadence for which one mode is opaque. */ +export const MODE_VISIBLE_FRACTION = 1 / MODE_WAVELENGTHS.length +/** The keyframe breakpoint, pre-formatted so CSS never recomputes it. */ +export const MODE_VISIBLE_PCT = `${(100 / MODE_WAVELENGTHS.length).toFixed(4)}%` + const SAMPLE_STEP = 0.6 -/** Quadrature: the companion is a quarter period behind. Derived from the period so the two - * numbers can never drift apart. */ +/** Quadrature: the companion is a quarter period out of phase with the lead. Derived from + * the period so the two numbers can never drift apart. */ export function companionDelayMs(periodMs: number = WAVE_PERIOD_MS): number { return -periodMs / 4 } @@ -61,8 +66,8 @@ export function modeDelaysMs( ): number[] { const cadence = modeCadenceMs(holdMs, modeCount) return Array.from({ length: modeCount }, (_, i) => { - const d = -(cadence - i * holdMs) - return d === -cadence ? 0 : d + if (i === 0) return 0 + return -(cadence - i * holdMs) }) } @@ -87,7 +92,11 @@ export function visibleModesAt( export function samplePoints(wavelength: number): Array<[number, number]> { const pts: Array<[number, number]> = [] const round = (n: number) => Math.round(n * 100) / 100 - for (let x = 0; x <= WAVE_BOX.w + 1e-9; x += SAMPLE_STEP) { + // Integer-indexed so the box width is always reached exactly — WAVE_BOX.w must divide + // evenly by SAMPLE_STEP (30 / 0.6 = 50 steps) or the wave falls short of the right edge. + const steps = Math.round(WAVE_BOX.w / SAMPLE_STEP) + for (let i = 0; i <= steps; i++) { + const x = i * SAMPLE_STEP pts.push([round(x), round(WAVE_BOX.mid - WAVE_AMP * Math.sin((2 * Math.PI * x) / wavelength))]) } return pts From e3b62cdcb0f501004890c295cf2f5e364a62f12b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 12:23:55 -0400 Subject: [PATCH 03/13] docs(amicode): record that the climb indexes wavelengths, not harmonic number The three curves are physically n=2,4,6 for a fixed-end string (lambda_n = 2L/n with L=30), labelled 1/2/3 because the index counts full wavelengths across the box. The consecutive set (lambda 60/30/20) was built and compared; the current set won on looks. Written down because this codebase's readers are quantum- control physicists who will reach for lambda_n = 2L/n and find the labels off by a factor of two. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 4691a0b962e98c4673bb491d47f02ad57b657aa4) --- packages/ui/src/amicode/amico-wave.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/amicode/amico-wave.ts b/packages/ui/src/amicode/amico-wave.ts index 88cbeefeb2..8decf0dc1f 100644 --- a/packages/ui/src/amicode/amico-wave.ts +++ b/packages/ui/src/amicode/amico-wave.ts @@ -29,7 +29,16 @@ export const WAVE_PERIOD_MS = 1150 export const WAVE_EASING = "cubic-bezier(.9,0,.1,1)" export const MODE_HOLD_MS = 2300 -/** Modes 1, 2, 3 across the 30px box. */ +/** + * The climb, indexed by FULL WAVELENGTHS across the box: 1, 2, 3. + * + * Deliberately not the physical harmonic number. A standing wave on a string of length L + * with both ends fixed admits only λₙ = 2L/n, so with L = 30 these three are physically + * n = 2, 4, 6 (node counts 3, 5, 7 — consistent with n+1, just even). The physically + * consecutive set would be λ = 60/30/20; it was built, compared side by side, and the + * current set was chosen on looks. Recording it here because this codebase's readers are + * quantum-control physicists who will reach for λₙ = 2L/n and find the labels off by 2×. + */ export const MODE_WAVELENGTHS = [30, 15, 10] as const /** Fraction of the cadence for which one mode is opaque. */ From 0b584999c3dbc3ab7418dc03bd11ec295a1d6d6b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 12:33:29 -0400 Subject: [PATCH 04/13] feat(amicode): AmicoWave component; retire the amc-text-shimmer treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SolidJS component (amico-wave.tsx) that renders the standing-wave glyph purely from amico-wave.ts's geometry/timing constants via CSS custom properties, plus the matching CSS block and an anti-drift test guarding the one unavoidable duplicated literal (the @keyframes percentage). Removes the now-superseded amc-text-shimmer treatment (leading dot + shimmer keyframes) from the thinking line. Also flips allowImportingTsExtensions on for packages/ui: this directory now has both amico-wave.ts (pure module) and amico-wave.tsx (component) sharing a stem, and under "bundler" resolution an extensionless "./amico-wave" import resolves to the .tsx sibling instead of the .ts module (confirmed empirically with a standalone repro) — the component would have silently self-imported. Pinning the import with an explicit ".ts" extension fixes it, and this flag is TypeScript's sanctioned way to permit that syntax under noEmit. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 7a7bcff7225bfd699d93b6e5d266635a9b33bf3f) --- packages/ui/src/amicode/amico-wave.test.ts | 19 ++++- packages/ui/src/amicode/amico-wave.tsx | 77 ++++++++++++++++++ packages/ui/src/amicode/amicode.css | 94 ++++++++++++++-------- packages/ui/tsconfig.json | 5 ++ 4 files changed, 160 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/amicode/amico-wave.tsx diff --git a/packages/ui/src/amicode/amico-wave.test.ts b/packages/ui/src/amicode/amico-wave.test.ts index fbca72cd11..72934a5dbc 100644 --- a/packages/ui/src/amicode/amico-wave.test.ts +++ b/packages/ui/src/amicode/amico-wave.test.ts @@ -1,4 +1,5 @@ // packages/ui/src/amicode/amico-wave.test.ts +import { readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" import { WAVE_BOX, @@ -15,7 +16,11 @@ import { modeDelaysMs, visibleModesAt, samplePoints, -} from "./amico-wave" + // Explicit extension — see the comment in amico-wave.tsx: this directory now has both + // amico-wave.ts and amico-wave.tsx sharing a stem, and an extensionless "./amico-wave" + // resolves to the .tsx sibling under this package's bundler resolution, not the .ts module + // these names actually live in. +} from "./amico-wave.ts" describe("quadrature", () => { test("companion delay is exactly a quarter period, derived from the period", () => { @@ -120,3 +125,15 @@ describe("geometry", () => { } }) }) + +describe("CSS/TS drift guard", () => { + test("the amc-wave-mode keyframe breakpoint still matches MODE_VISIBLE_PCT", () => { + // @keyframes selectors cannot use custom properties, so this one number is duplicated + // in amicode.css by necessity. Assert the duplicate rather than trusting it. + const css = readFileSync(new URL("./amicode.css", import.meta.url), "utf8") + const start = css.indexOf("@keyframes amc-wave-mode") + expect(start).toBeGreaterThan(-1) + const block = css.slice(start, css.indexOf("}", css.indexOf("opacity: 0", start))) + expect(block).toContain(MODE_VISIBLE_PCT) + }) +}) diff --git a/packages/ui/src/amicode/amico-wave.tsx b/packages/ui/src/amicode/amico-wave.tsx new file mode 100644 index 0000000000..c356ca6568 --- /dev/null +++ b/packages/ui/src/amicode/amico-wave.tsx @@ -0,0 +1,77 @@ +// AMICODE: the harmonic working indicator — a standing wave in quadrature, shown while +// Amico works. Replaces the amc-text-shimmer treatment on both indicator surfaces. +// +// Markup only: all geometry and timing come from ./amico-wave, handed to the CSS as custom +// properties so there is exactly one source of truth. Two paths per mode (lead + companion +// out of phase by a quarter period); the companion is at full swing exactly when the lead +// crosses zero, which is what stops the glyph reading as a blink at 12px. +// +// NO and NO ids — several indicators mount at once (one thinking line plus one tool +// header per tool call) and SVG ids are document-global, so ids would collide and every +// instance would resolve to the first definition. If a variant ever needs masking it must +// use CSS mask-image, not an SVG . +import { For } from "solid-js" +import { + WAVE_BOX, + WAVE_LEAD_STROKE, + WAVE_COMPANION_STROKE, + WAVE_COMPANION_OPACITY, + WAVE_PERIOD_MS, + WAVE_EASING, + MODE_PATHS, + companionDelayMs, + modeCadenceMs, + modeDelaysMs, + // Explicit extension: this package's bundler resolution (moduleResolution: "bundler") + // prefers a sibling .tsx over .ts for an extensionless specifier, and this directory now + // has both amico-wave.ts (pure module) and amico-wave.tsx (this file) sharing a stem. An + // extensionless "./amico-wave" here would resolve back to THIS file, not the pure module — + // confirmed by running it (Bun picks .tsx first). The explicit extension pins the target. +} from "./amico-wave.ts" + +const DELAYS = modeDelaysMs() + +export function AmicoWave(props: { class?: string }) { + return ( + + ) +} diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 7524aeb3a8..30371dec2e 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -54,8 +54,8 @@ /* ---- thinking line (Claude-Code-esque working indicator) ----------------- */ /* Sits after the AMICO wordmark while a reply streams (thinking-line.tsx). The - * gerund word cycles + shimmers; the meta line ticks elapsed (· tokens · esc). - * Leading dot separates it from the wordmark. */ + * gerund word cycles without shimmer — the wave glyph (amico-wave.tsx) carries + * the motion now; the meta line ticks elapsed (· tokens · esc). */ .amc-thinking { display: inline-flex; align-items: baseline; @@ -65,43 +65,11 @@ letter-spacing: 0.01em; color: var(--v2-text-text-muted); } -.amc-thinking::before { - content: ""; - align-self: center; - width: 3px; - height: 3px; - border-radius: 50%; - background: var(--v2-border-border-base); - flex-shrink: 0; -} .amc-thinking-word { font-weight: 600; white-space: nowrap; color: var(--v2-text-text-accent); } -/* The shimmer: a bright band sweeps across otherwise-dimmed accent text. - * Motion-gated — under reduced motion (or .is-still) the word is plain accent. */ -@media (prefers-reduced-motion: no-preference) { - .amc-thinking:not(.is-still) .amc-thinking-word { - color: transparent; - background-image: linear-gradient( - 100deg, - color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 0%, - color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 38%, - var(--v2-text-text-accent) 50%, - color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 62%, - color-mix(in srgb, var(--v2-text-text-accent) 42%, transparent) 100% - ); - background-size: 220% 100%; - -webkit-background-clip: text; - background-clip: text; - animation: amc-text-shimmer 1.6s linear infinite; - } -} -@keyframes amc-text-shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -20% 0; } -} .amc-thinking-meta { display: inline-flex; align-items: baseline; @@ -112,6 +80,64 @@ .amc-thinking-sep { opacity: 0.55; } .amc-thinking-hint { font-style: italic; } +/* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */ +/* A standing wave in quadrature: two identical curves, the companion a quarter period out + * of phase, so one is at full swing whenever the other crosses zero. + * + * Every duration, delay and opacity here comes from amico-wave.ts via a custom property + * set inline by the component — do NOT restate those numbers in this file. + * + * Colour follows the `ink` role from brand_accent.ts: the brand lemon is a FILL, never an + * INK (1.1:1 on white), so a stroked glyph carries lemon on dark and a neutral legible + * foreground on light. It does NOT take the hairline edge that lemon fills get — a 1.5px + * stroke cannot carry a 1px edge. + * + * Theme idiom in this repo: :root is light, :root:not([data-theme="light"]) is dark. */ +.amc-wave { + display: block; + flex-shrink: 0; + color: var(--v2-text-text-base); +} +:root:not([data-theme="light"]) .amc-wave { + color: var(--accent, #fff676); +} + +.amc-wave-ln { + transform-box: view-box; + transform-origin: 50% 50%; + animation: amc-wave-stand var(--amc-wave-period) var(--amc-wave-ease) infinite; +} +.amc-wave-ln[data-role="companion"] { + opacity: var(--amc-wave-comp-op); + animation-delay: var(--amc-wave-quad); +} +@keyframes amc-wave-stand { + 0%, 100% { transform: scaleY(1); } + 50% { transform: scaleY(-1); } +} + +.amc-wave-mode { + animation: amc-wave-mode var(--amc-wave-cadence) steps(1) infinite; +} +/* THE ONE UNAVOIDABLE DUPLICATION. 33.3333% is MODE_VISIBLE_PCT from amico-wave.ts, and it + * cannot be a var(): @keyframes selectors are not a property-value context, so custom + * properties are invalid there. Writing 33% instead would leave a ~23ms window each cadence + * with NO mode visible — a per-cycle flicker. A test in amico-wave.test.ts reads this file + * and asserts this literal still matches the constant, so the two cannot drift. */ +@keyframes amc-wave-mode { + 0%, 33.3333% { opacity: 1; } + 33.3334%, 100% { opacity: 0; } +} + +/* Static mode-1 curve, companion hidden. The elapsed counter in the thinking line keeps + * ticking — it is information, not decoration. */ +@media (prefers-reduced-motion: reduce) { + .amc-wave-ln, + .amc-wave-mode { animation: none !important; } + .amc-wave-ln[data-role="companion"] { opacity: 0 !important; } + .amc-wave-mode:not(:first-child) { opacity: 0 !important; } +} + /* ============================================================ THE CHIP — [data-component="amicode-card"] ============================================================ */ diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 25f6a3dcc4..30cc732ac4 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -12,6 +12,11 @@ "isolatedModules": true, "module": "ESNext", "moduleResolution": "bundler", + // amico-wave.ts (pure module) and amico-wave.tsx (component) share a stem; under + // "bundler" resolution an extensionless "./amico-wave" resolves to the .tsx sibling, + // not the .ts module, so the import needs an explicit ".ts" extension to disambiguate. + // Requires noEmit (already set) per the TS5097 diagnostic this silences. + "allowImportingTsExtensions": true, "noEmit": true, "lib": ["es2023", "dom", "dom.iterable"], // Type Checking & Safety From 38690c5c1a141a2cf8c690cfeb3e039ccabd269c Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 12:39:08 -0400 Subject: [PATCH 05/13] refactor(amicode): rename the pure module to wave-geometry, dropping the .ts/.tsx stem collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amico-wave.ts and amico-wave.tsx shared a stem, and bun resolves an extensionless import of that stem to the .tsx — so the component self-imported. Fixed by renaming rather than by relaxing tsconfig: the directory convention (thinking.ts / thinking-line.tsx) already avoids this by construction. Reverts allowImportingTsExtensions and adds a test that fails if any same-stem pair reappears in this directory. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 3b7b3b40c4d9985892b45cf800e71a9e1e83afd8) --- packages/ui/src/amicode/amico-wave.tsx | 15 +++++-------- packages/ui/src/amicode/amicode.css | 6 +++--- ...ico-wave.test.ts => wave-geometry.test.ts} | 21 ++++++++++++------- .../{amico-wave.ts => wave-geometry.ts} | 2 +- packages/ui/tsconfig.json | 5 ----- 5 files changed, 23 insertions(+), 26 deletions(-) rename packages/ui/src/amicode/{amico-wave.test.ts => wave-geometry.test.ts} (88%) rename packages/ui/src/amicode/{amico-wave.ts => wave-geometry.ts} (99%) diff --git a/packages/ui/src/amicode/amico-wave.tsx b/packages/ui/src/amicode/amico-wave.tsx index c356ca6568..6d56795602 100644 --- a/packages/ui/src/amicode/amico-wave.tsx +++ b/packages/ui/src/amicode/amico-wave.tsx @@ -1,10 +1,10 @@ // AMICODE: the harmonic working indicator — a standing wave in quadrature, shown while // Amico works. Replaces the amc-text-shimmer treatment on both indicator surfaces. // -// Markup only: all geometry and timing come from ./amico-wave, handed to the CSS as custom -// properties so there is exactly one source of truth. Two paths per mode (lead + companion -// out of phase by a quarter period); the companion is at full swing exactly when the lead -// crosses zero, which is what stops the glyph reading as a blink at 12px. +// Markup only: all geometry and timing come from ./wave-geometry, handed to the CSS as +// custom properties so there is exactly one source of truth. Two paths per mode (lead + +// companion out of phase by a quarter period); the companion is at full swing exactly when +// the lead crosses zero, which is what stops the glyph reading as a blink at 12px. // // NO and NO ids — several indicators mount at once (one thinking line plus one tool // header per tool call) and SVG ids are document-global, so ids would collide and every @@ -22,12 +22,7 @@ import { companionDelayMs, modeCadenceMs, modeDelaysMs, - // Explicit extension: this package's bundler resolution (moduleResolution: "bundler") - // prefers a sibling .tsx over .ts for an extensionless specifier, and this directory now - // has both amico-wave.ts (pure module) and amico-wave.tsx (this file) sharing a stem. An - // extensionless "./amico-wave" here would resolve back to THIS file, not the pure module — - // confirmed by running it (Bun picks .tsx first). The explicit extension pins the target. -} from "./amico-wave.ts" +} from "./wave-geometry" const DELAYS = modeDelaysMs() diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 30371dec2e..4fa70e5808 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -84,7 +84,7 @@ /* A standing wave in quadrature: two identical curves, the companion a quarter period out * of phase, so one is at full swing whenever the other crosses zero. * - * Every duration, delay and opacity here comes from amico-wave.ts via a custom property + * Every duration, delay and opacity here comes from wave-geometry.ts via a custom property * set inline by the component — do NOT restate those numbers in this file. * * Colour follows the `ink` role from brand_accent.ts: the brand lemon is a FILL, never an @@ -119,10 +119,10 @@ .amc-wave-mode { animation: amc-wave-mode var(--amc-wave-cadence) steps(1) infinite; } -/* THE ONE UNAVOIDABLE DUPLICATION. 33.3333% is MODE_VISIBLE_PCT from amico-wave.ts, and it +/* THE ONE UNAVOIDABLE DUPLICATION. 33.3333% is MODE_VISIBLE_PCT from wave-geometry.ts, and it * cannot be a var(): @keyframes selectors are not a property-value context, so custom * properties are invalid there. Writing 33% instead would leave a ~23ms window each cadence - * with NO mode visible — a per-cycle flicker. A test in amico-wave.test.ts reads this file + * with NO mode visible — a per-cycle flicker. A test in wave-geometry.test.ts reads this file * and asserts this literal still matches the constant, so the two cannot drift. */ @keyframes amc-wave-mode { 0%, 33.3333% { opacity: 1; } diff --git a/packages/ui/src/amicode/amico-wave.test.ts b/packages/ui/src/amicode/wave-geometry.test.ts similarity index 88% rename from packages/ui/src/amicode/amico-wave.test.ts rename to packages/ui/src/amicode/wave-geometry.test.ts index 72934a5dbc..47b72a7934 100644 --- a/packages/ui/src/amicode/amico-wave.test.ts +++ b/packages/ui/src/amicode/wave-geometry.test.ts @@ -1,5 +1,5 @@ -// packages/ui/src/amicode/amico-wave.test.ts -import { readFileSync } from "node:fs" +// packages/ui/src/amicode/wave-geometry.test.ts +import { readdirSync, readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" import { WAVE_BOX, @@ -16,11 +16,7 @@ import { modeDelaysMs, visibleModesAt, samplePoints, - // Explicit extension — see the comment in amico-wave.tsx: this directory now has both - // amico-wave.ts and amico-wave.tsx sharing a stem, and an extensionless "./amico-wave" - // resolves to the .tsx sibling under this package's bundler resolution, not the .ts module - // these names actually live in. -} from "./amico-wave.ts" +} from "./wave-geometry" describe("quadrature", () => { test("companion delay is exactly a quarter period, derived from the period", () => { @@ -137,3 +133,14 @@ describe("CSS/TS drift guard", () => { expect(block).toContain(MODE_VISIBLE_PCT) }) }) + +describe("module/component naming", () => { + test("no same-stem .ts/.tsx pair in this directory — bun resolves such imports to the .tsx", () => { + const dir = new URL(".", import.meta.url) + const names = readdirSync(dir) + const stem = (f: string) => f.replace(/\.(tsx?|test\.ts)$/, "") + const ts = new Set(names.filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts")).map(stem)) + const collisions = names.filter((f) => f.endsWith(".tsx") && ts.has(stem(f))) + expect(collisions).toEqual([]) + }) +}) diff --git a/packages/ui/src/amicode/amico-wave.ts b/packages/ui/src/amicode/wave-geometry.ts similarity index 99% rename from packages/ui/src/amicode/amico-wave.ts rename to packages/ui/src/amicode/wave-geometry.ts index 8decf0dc1f..f1eb29bc68 100644 --- a/packages/ui/src/amicode/amico-wave.ts +++ b/packages/ui/src/amicode/wave-geometry.ts @@ -1,4 +1,4 @@ -// packages/ui/src/amicode/amico-wave.ts +// packages/ui/src/amicode/wave-geometry.ts // AMICODE: pure geometry + timing for the harmonic working indicator (amico-wave.tsx). // Spec: spec-20260728-104232-amicode-working-indicator-harmonic-wave. // diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 30cc732ac4..25f6a3dcc4 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -12,11 +12,6 @@ "isolatedModules": true, "module": "ESNext", "moduleResolution": "bundler", - // amico-wave.ts (pure module) and amico-wave.tsx (component) share a stem; under - // "bundler" resolution an extensionless "./amico-wave" resolves to the .tsx sibling, - // not the .ts module, so the import needs an explicit ".ts" extension to disambiguate. - // Requires noEmit (already set) per the TS5097 diagnostic this silences. - "allowImportingTsExtensions": true, "noEmit": true, "lib": ["es2023", "dom", "dom.iterable"], // Type Checking & Safety From d7899acdb47e4c686d84190e3568fa30b517ec09 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 12:55:32 -0400 Subject: [PATCH 06/13] fix(amicode): use the theme's ink token for the wave; tighten the CSS drift guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :root:not([data-theme="light"]) selector was derived from CSS that is inside a block comment in v2/styles/theme.css. data-theme carries a theme id, not a scheme, so the selector matched every reachable state and the wave rendered lemon at ~1.1:1 on light themes. oc-2.json already encodes the ink role in v2-icon-icon-accent (grey-800 light, #FFF676 dark), which is what amico-presence.css uses, so no theme selector is needed at all. Also tightens the amc-wave-mode CSS/TS drift guard: the old toContain() check passed under mutations that break the animation (ON edge dragged to 50%, OFF edge dragged to 40%) because the literal could still appear elsewhere in a loosely-bounded slice. Now asserts each breakpoint against its own selector+brace, and the previously-unguarded OFF literal is exported as MODE_OFF_PCT and checked too. Adds a MODE_HOLD_MS % WAVE_PERIOD_MS invariant test, and rewords two comments that described the component as already mounted (it isn't yet — a later task wires it in). Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit fdab7d9b4575dfa244f04ff86cfb5ade178c5974) --- packages/ui/src/amicode/amico-wave.tsx | 3 +- packages/ui/src/amicode/amicode.css | 28 +++++++-------- packages/ui/src/amicode/wave-geometry.test.ts | 34 ++++++++++++++++--- packages/ui/src/amicode/wave-geometry.ts | 2 ++ 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/amicode/amico-wave.tsx b/packages/ui/src/amicode/amico-wave.tsx index 6d56795602..edd93baed4 100644 --- a/packages/ui/src/amicode/amico-wave.tsx +++ b/packages/ui/src/amicode/amico-wave.tsx @@ -1,5 +1,6 @@ // AMICODE: the harmonic working indicator — a standing wave in quadrature, shown while -// Amico works. Replaces the amc-text-shimmer treatment on both indicator surfaces. +// Amico works. Will replace the amc-text-shimmer treatment on both indicator surfaces once +// mounted — that wiring (thinking-line.tsx and the tool header) is a later task. // // Markup only: all geometry and timing come from ./wave-geometry, handed to the CSS as // custom properties so there is exactly one source of truth. Two paths per mode (lead + diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 4fa70e5808..7659525f03 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -54,8 +54,8 @@ /* ---- thinking line (Claude-Code-esque working indicator) ----------------- */ /* Sits after the AMICO wordmark while a reply streams (thinking-line.tsx). The - * gerund word cycles without shimmer — the wave glyph (amico-wave.tsx) carries - * the motion now; the meta line ticks elapsed (· tokens · esc). */ + * gerund word cycles without shimmer — the wave glyph (amico-wave.tsx) will carry + * the motion once mounted (a later task); the meta line ticks elapsed (· tokens · esc). */ .amc-thinking { display: inline-flex; align-items: baseline; @@ -85,23 +85,23 @@ * of phase, so one is at full swing whenever the other crosses zero. * * Every duration, delay and opacity here comes from wave-geometry.ts via a custom property - * set inline by the component — do NOT restate those numbers in this file. - * - * Colour follows the `ink` role from brand_accent.ts: the brand lemon is a FILL, never an - * INK (1.1:1 on white), so a stroked glyph carries lemon on dark and a neutral legible - * foreground on light. It does NOT take the hairline edge that lemon fills get — a 1.5px - * stroke cannot carry a 1px edge. - * - * Theme idiom in this repo: :root is light, :root:not([data-theme="light"]) is dark. */ + * set inline by the component — do NOT restate those numbers in this file. */ .amc-wave { display: block; flex-shrink: 0; - color: var(--v2-text-text-base); -} -:root:not([data-theme="light"]) .amc-wave { - color: var(--accent, #fff676); + /* The ink role, already resolved by the active theme: oc-2.json maps + * v2-icon-icon-accent to grey-800 on light and #FFF676 on dark. The brand lemon is a + * fill, never an ink (1.1:1 on white), and this token is what encodes that — no theme + * selector needed here. Same treatment as amico-presence.css. */ + color: var(--v2-icon-icon-accent); } +/* .amc-wave-ln / .amc-wave-mode are component-private — only amico-wave.tsx ever sets the + * custom properties these animation shorthands read. Both shorthands carry a var(), which + * makes the whole shorthand a pending-substitution value: if any referenced custom property + * is ever missing (e.g. someone hand-writes in a fixture without the + * component's inline style block), the ENTIRE shorthand goes invalid and silently falls back + * to no animation — a static glyph, no console warning. */ .amc-wave-ln { transform-box: view-box; transform-origin: 50% 50%; diff --git a/packages/ui/src/amicode/wave-geometry.test.ts b/packages/ui/src/amicode/wave-geometry.test.ts index 47b72a7934..7e048e4ffa 100644 --- a/packages/ui/src/amicode/wave-geometry.test.ts +++ b/packages/ui/src/amicode/wave-geometry.test.ts @@ -11,6 +11,7 @@ import { MODE_PATHS, MODE_VISIBLE_FRACTION, MODE_VISIBLE_PCT, + MODE_OFF_PCT, companionDelayMs, modeCadenceMs, modeDelaysMs, @@ -65,6 +66,13 @@ describe("harmonic climb", () => { expect(MODE_VISIBLE_FRACTION).toBeCloseTo(MODE_HOLD_MS / modeCadenceMs(), 10) expect(MODE_VISIBLE_PCT).toBe("33.3333%") }) + + test("MODE_HOLD_MS is an exact multiple of WAVE_PERIOD_MS — every swap lands at stand-progress zero", () => { + // 2300 = 2 x 1150, so a mode swap always coincides with all curves at full swing — the + // cleanest possible cut. Nothing else defends this: changing the period to 1000 would + // make swaps land mid-swing and visibly jump, with every other test still green. + expect(MODE_HOLD_MS % WAVE_PERIOD_MS).toBe(0) + }) }) describe("geometry", () => { @@ -123,14 +131,30 @@ describe("geometry", () => { }) describe("CSS/TS drift guard", () => { - test("the amc-wave-mode keyframe breakpoint still matches MODE_VISIBLE_PCT", () => { - // @keyframes selectors cannot use custom properties, so this one number is duplicated - // in amicode.css by necessity. Assert the duplicate rather than trusting it. + test("the amc-wave-mode ON breakpoint still matches MODE_VISIBLE_PCT", () => { + // @keyframes selectors cannot use custom properties, so this literal is duplicated in + // amicode.css by necessity. A loose toContain() over a wide slice is not enough — it + // still passes if the ON edge is mutated to e.g. "0%, 50%" (mode curves permanently + // overlaid), because the original percentage string can still appear elsewhere in the + // slice. Bind the match to the rule's own selector+brace instead. + const css = readFileSync(new URL("./amicode.css", import.meta.url), "utf8") + const start = css.indexOf("@keyframes amc-wave-mode") + expect(start).toBeGreaterThan(-1) + const block = css.slice(start, css.indexOf("}", css.indexOf("{", start) + 1) + 1) + expect(block).toMatch(new RegExp(`0%,\\s*${MODE_VISIBLE_PCT.replace(".", "\\.")}\\s*\\{`)) + }) + + test("the amc-wave-mode OFF breakpoint still matches MODE_OFF_PCT", () => { + // The OFF edge is the one that actually decides whether two modes ever render + // superimposed — a guard that only checks the ON edge would pass with the OFF edge + // dragged to e.g. "40%", which shows two wavelengths superimposed for ~460ms of every + // transition. Same bounded-match technique, applied to the second rule. const css = readFileSync(new URL("./amicode.css", import.meta.url), "utf8") const start = css.indexOf("@keyframes amc-wave-mode") expect(start).toBeGreaterThan(-1) - const block = css.slice(start, css.indexOf("}", css.indexOf("opacity: 0", start))) - expect(block).toContain(MODE_VISIBLE_PCT) + const onEnd = css.indexOf("}", css.indexOf("{", start) + 1) + 1 + const block = css.slice(onEnd, css.indexOf("}", css.indexOf("{", onEnd) + 1) + 1) + expect(block).toMatch(new RegExp(`${MODE_OFF_PCT.replace(".", "\\.")},\\s*100%\\s*\\{`)) }) }) diff --git a/packages/ui/src/amicode/wave-geometry.ts b/packages/ui/src/amicode/wave-geometry.ts index f1eb29bc68..26e2c95bf6 100644 --- a/packages/ui/src/amicode/wave-geometry.ts +++ b/packages/ui/src/amicode/wave-geometry.ts @@ -45,6 +45,8 @@ export const MODE_WAVELENGTHS = [30, 15, 10] as const export const MODE_VISIBLE_FRACTION = 1 / MODE_WAVELENGTHS.length /** The keyframe breakpoint, pre-formatted so CSS never recomputes it. */ export const MODE_VISIBLE_PCT = `${(100 / MODE_WAVELENGTHS.length).toFixed(4)}%` +/** The OFF breakpoint — one ten-thousandth after the ON edge, so the swap is a hard cut. */ +export const MODE_OFF_PCT = "33.3334%" const SAMPLE_STEP = 0.6 From 7d3573fc2c5d489808432d61dcfc6fb934782b12 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 13:18:31 -0400 Subject: [PATCH 07/13] =?UTF-8?q?docs(amicode):=20AmicoWave=20stories=20?= =?UTF-8?q?=E2=80=94=20scheme=20contrast=20and=20multi-instance=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 158f522c0374d99967fb2efe73569f01ac24bb56) --- .../ui/src/amicode/amico-wave.stories.tsx | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 packages/ui/src/amicode/amico-wave.stories.tsx diff --git a/packages/ui/src/amicode/amico-wave.stories.tsx b/packages/ui/src/amicode/amico-wave.stories.tsx new file mode 100644 index 0000000000..bba204a53a --- /dev/null +++ b/packages/ui/src/amicode/amico-wave.stories.tsx @@ -0,0 +1,313 @@ +// @ts-nocheck +// AmicoWave: the harmonic working indicator. Geometry/timing live in wave-geometry.ts and are +// unit-tested there; this file is the only place the SVG + CSS actually render, so it is also +// the only place a CSS regression (wrong ink, broken quadrature, id collisions across +// simultaneous mounts) would be visible before it ships. Nothing mounts AmicoWave in the app +// yet — see amico-wave.tsx's header comment — so these stories are the sole way to see it. +// +// ---- why Default/Schemes exist ------------------------------------------------------------- +// A review caught a critical color bug that no unit test could have caught: the component +// used to pick its dark-scheme ink via `:root:not([data-theme="light"])`, a selector copied +// from a block in v2/styles/theme.css that lives INSIDE a /* */ comment — dead CSS — so it +// matched in every state and the glyph rendered brand lemon (~1.1:1 contrast) on light +// backgrounds. It's fixed to `color: var(--v2-icon-icon-accent)`, which the active theme +// (oc-2) maps to grey-800 on light and #FFF676 on dark. Default and Schemes below exist so +// that mapping can never silently regress again. +// +// ---- the wrinkle: how these stories actually re-resolve the tokens ------------------------ +// The obvious approach — set `data-color-scheme="light"`/`"dark"` on a wrapper element, per +// theme/context.tsx (`dataset.colorScheme`) and v2/styles/theme.css's +// `[data-color-scheme="light"]` / `[data-color-scheme="dark"]` blocks — turns out to be a +// no-op INSIDE STORYBOOK. Storybook's preview only imports `@opencode-ai/ui/styles/tailwind`, +// whose theme file is the v1 `styles/theme.css`; the v2 file that defines those +// `[data-color-scheme]` blocks is only ever pulled in by the real app's +// `v2/styles/tailwind.css` chain (packages/app/src/index.css), which Storybook never loads. +// Verified empirically with Playwright against a running Storybook: a nested +// `data-color-scheme="light"` (or "dark") div's `--v2-*` custom properties were untouched by +// the attribute and simply inherited whatever the global theme toolbar had already put on +// — both a "light" and a "dark" test div resolved to the SAME `--v2-icon-icon-accent`. +// So a story built the literal way the review described would silently show the SAME color +// twice, which is exactly the failure mode it was meant to catch. +// +// Instead, each scheme pane below calls the SAME resolver the app uses at runtime — +// `resolveThemeVariantV2` over the real oc-2.json theme (theme/v2/resolve.ts, the function +// theme/context.tsx's applyThemeCss calls) — and applies the FULL resulting token set +// (188 keys: primitive ramps + semantic aliases, self-contained) as inline custom properties +// on that pane's own wrapper. That correctly re-resolves `--v2-icon-icon-accent` (and every +// other v2 token used inside) independent of whatever the global Storybook theme toggle is +// doing, because inline-set custom properties on an element always win for that subtree. This +// is arguably MORE faithful than the dead attribute would have been: it reads the live oc-2 +// 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 { MODE_WAVELENGTHS, WAVE_BOX } from "./wave-geometry" +import oc2ThemeJson from "../theme/themes/oc-2.json" +import { resolveThemeVariantV2 } from "../theme/v2/resolve" +import type { DesktopTheme } from "../theme/types" + +const oc2Theme = oc2ThemeJson as DesktopTheme + +/** The real oc-2 --v2-* token set for one color scheme, self-contained (primitive ramps + + * semantic aliases both included), so it can be applied to any wrapper and resolve correctly + * with zero dependency on ambient state — in particular, independent of the Storybook global + * theme toolbar. This is the exact function theme/context.tsx calls to paint ; here we + * scope its output to a
instead. */ +function schemeVars(scheme: "light" | "dark"): Record { + const isDark = scheme === "dark" + const tokens = resolveThemeVariantV2(isDark ? oc2Theme.dark : oc2Theme.light, isDark) + const vars: Record = {} + for (const [key, value] of Object.entries(tokens)) vars[`--${key}`] = value + return vars +} + +// Scale + freeze helpers used by Schemes/Modes/Magnified/ReducedMotion below. AmicoWave sets +// width/height as SVG presentation attributes (30x12); a plain CSS class rule already beats +// those with no !important needed, per the CSS spec's presentation-attribute priority rule. +const StoryCss = () => ( + +) + +function SchemePane(props: { scheme: "light" | "dark"; children: unknown }) { + return ( +
+
+ {props.scheme} scheme +
+ {props.children} +
+ ) +} + +// Mimics the real mount site (thinking-line.tsx, once wired): glyph, bold gerund, muted +// elapsed time — reusing the actual .amc-thinking* classes from amicode.css. +const ThinkingRow = () => ( + + + Percolating + 5m 13s + +) + +export default { + title: "Amicode/AmicoWave", + id: "amicode-amico-wave", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### AmicoWave + +The harmonic working indicator: a 30×12px standing wave in quadrature (a lead curve and a +fainter companion, a quarter period out of phase, so one is at full swing exactly when the +other crosses zero — what keeps the glyph from reading as a blink at 12px). It climbs through +three modes (1/2/3 full wavelengths across the box) on a slower cadence. All geometry and +timing come from wave-geometry.ts via CSS custom properties set inline by the component — +never restated here. + +Not yet mounted anywhere in the app (a later task will wire it into the thinking line and the +tool header, replacing the amc-text-shimmer treatment) — these stories are the only current +way to see it render. + +**Hard invariant, guarded by ManyInstances below:** no SVG \`\`, no \`id\` attributes. +SVG ids are document-global, and several indicators mount at once in real use (one thinking +line plus one tool header per in-flight tool call) — an id would collide across instances.`, + }, + }, + }, +} + +// --------------------------------------------------------------------------------------------- +// Default — the glyph at natural size beside its real-use text, in both schemes side by side +// so an ink regression (wrong token, or a hardcoded color that ignores scheme entirely) is +// visible without needing the Schemes story's numeric readout. +export const Default = () => ( +
+ + + + + + +
+) + +// --------------------------------------------------------------------------------------------- +// Schemes — the contrast check. getComputedStyle(el).color reads what the browser ACTUALLY +// resolved for that scheme's pane, not what the token mapping merely intends. This is the +// story that would have caught the original bug: the buggy selector lived in a dead comment, +// so the glyph's color never actually changed with scheme — it would have printed the SAME +// resolved color under both panes here. +function ContrastSwatch(props: { scheme: "light" | "dark" }) { + const [resolved, setResolved] = createSignal("…") + let wrap: HTMLDivElement | undefined + onMount(() => { + const svg = wrap?.querySelector('[data-component="amico-wave"]') + if (svg) setResolved(getComputedStyle(svg).color) + }) + return ( +
+
+ {props.scheme} +
+
+ +
+ + getComputedStyle → {resolved()} + +
+ ) +} + +export const Schemes = () => ( + <> + +
+ + +
+ +) + +// --------------------------------------------------------------------------------------------- +// Modes — each of the three standing modes, frozen and isolated, at 4x, so the shape reads. +// Wavelength is indexed by full wavelengths across the 30px box (1, 2, 3) — deliberately NOT +// the physical harmonic number; see wave-geometry.ts's MODE_WAVELENGTHS comment for why. +function FrozenMode(props: { pin: number; wavelength: number; waves: number }) { + return ( +
+
+ +
+
+ mode {props.pin} — λ={props.wavelength}px, {props.waves} full wavelength{props.waves === 1 ? "" : "s"} across + the box +
+
+ ) +} + +export const Modes = () => ( + <> + +
+ + {(wavelength, i) => } + +
+ +) + +// --------------------------------------------------------------------------------------------- +// Magnified — one live glyph at 6x so the quadrature is visible at a glance: the faint +// companion is at full swing exactly when the bold lead crosses the axis. +export const Magnified = () => ( + <> + +
+ +

+ The companion (faint, 0.4 opacity) is a quarter period behind the lead — it peaks + exactly when the lead crosses zero, which is what stops the glyph reading as a blink at + the real 12px size. +

+
+ +) + +// --------------------------------------------------------------------------------------------- +// ManyInstances — the regression guard for the no-/no-id rule. SVG ids are +// document-global: if one is ever introduced (for a mask, a gradient, anything), every +// instance after the first resolves to the FIRST element's definition and visibly breaks — +// invisibly on inspection of a single instance, but obvious the moment two or more are mounted +// at once, which is the normal case in the real app (one thinking line plus one tool header +// per in-flight tool call). +export const ManyInstances = () => ( +
+

+ 12 live instances, mounted simultaneously. AmicoWave has no SVG <defs> and no id + attribute anywhere — this is the guard for that: if either is ever added, ids collide + across instances and every glyph after the first will visibly break here (even though a + single isolated instance would still look correct). +

+
+ {() => } +
+
+) + +// --------------------------------------------------------------------------------------------- +// ReducedMotion — a mockup of the CSS's own @media (prefers-reduced-motion: reduce) fallback +// (amicode.css): one static mode-1 curve, companion hidden, no animation. This is authored +// locally to DEMONSTRATE the intended fallback, not to test it live — Storybook cannot force +// the OS/browser reduced-motion setting. To see the real fallback, enable "reduce motion" at +// the OS level (or the equivalent DevTools rendering emulation) and reload; every AmicoWave in +// every story on this page will then freeze the same way. +export const ReducedMotion = () => ( + <> + +
+
+ +
+

+ Mockup only — driven by the OS/browser prefers-reduced-motion setting in + the real component, which Storybook cannot toggle. The elapsed counter in the thinking + line keeps ticking under reduced motion; only this glyph's own animation stops. +

+
+ +) From 2585413ca781c483bee8054a918e8967981b88a0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 13:44:36 -0400 Subject: [PATCH 08/13] feat(amicode): thinking line carries the harmonic wave; H-mark goes static (cherry picked from commit 2270998accc8a1c27433805ffda28223aab5a460) --- packages/ui/src/amicode/amicode.css | 2 ++ .../ui/src/amicode/thinking-line.stories.tsx | 13 +++++++------ packages/ui/src/amicode/thinking-line.tsx | 19 +++++++++++-------- packages/ui/src/components/message-part.tsx | 4 +++- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 7659525f03..29c3caf6a8 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -79,6 +79,8 @@ } .amc-thinking-sep { opacity: 0.55; } .amc-thinking-hint { font-style: italic; } +/* The glyph is a block, not text — baseline alignment would sit it high. */ +.amc-thinking .amc-wave { align-self: center; } /* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */ /* A standing wave in quadrature: two identical curves, the companion a quarter period out diff --git a/packages/ui/src/amicode/thinking-line.stories.tsx b/packages/ui/src/amicode/thinking-line.stories.tsx index ede0395dd5..f6c8ed7995 100644 --- a/packages/ui/src/amicode/thinking-line.stories.tsx +++ b/packages/ui/src/amicode/thinking-line.stories.tsx @@ -12,12 +12,13 @@ export default { component: `### Thinking line The Claude-Code-esque "working" indicator shown beside the AMICO turn signature -while a reply streams. The H-mark glyph pulses (brand motif); the gerund word -cycles every ~2s and shimmers; the meta line ticks elapsed time (and, when the -mount site provides them, token count + an "esc to interrupt" hint). +while a reply streams. The H-mark glyph is static; the harmonic wave glyph +carries the motion, the gerund word cycles every ~2s with no shimmer, and the +meta line ticks elapsed time (and, when the mount site provides them, token +count + an "esc to interrupt" hint). -Under \`prefers-reduced-motion\` the word is static and un-shimmered; the elapsed -counter still advances.`, +Under \`prefers-reduced-motion\` the wave and word are static; the elapsed +counter still advances (it's information, not decoration).`, }, }, }, @@ -25,7 +26,7 @@ counter still advances.`, const Frame = (props) => ( - + AMICO {props.children} diff --git a/packages/ui/src/amicode/thinking-line.tsx b/packages/ui/src/amicode/thinking-line.tsx index fea06b5b1b..f1d89e46c9 100644 --- a/packages/ui/src/amicode/thinking-line.tsx +++ b/packages/ui/src/amicode/thinking-line.tsx @@ -1,16 +1,19 @@ import { createSignal, onCleanup, onMount, Show, type ComponentProps } from "solid-js" import { wordAt, formatElapsed, formatTokens } from "./thinking" +import { AmicoWave } from "./amico-wave" // AMICODE: the "thinking" working indicator — a Claude-Code-esque line shown // beside the AMICO turn signature while a reply streams (message-part.tsx). The -// H-mark glyph (AmicoMark, already in the signature) stays the brand motif; this -// adds the cycling, shimmering gerund word + a live meta line (elapsed · tokens). -// Pure bits (word rotation, label formatting) live in ./thinking for testing. +// H-mark glyph beside this line is now static; motion lives here instead, in the +// AmicoWave glyph (amico-wave.tsx) plus the cycling gerund word + a live meta +// line (elapsed · tokens). Pure bits (word rotation, label formatting) live in +// ./thinking for testing. // -// Motion: the word swaps on a ~2s timer and shimmers via amc-text-shimmer -// (amicode.css). Under prefers-reduced-motion the word is static ("Thinking…") -// with no shimmer; the elapsed counter still advances (it's information, not -// decoration). Timers clear onCleanup so a finished turn stops ticking. +// Motion: the wave animates continuously (CSS, amicode.css) and the word swaps +// on a ~2s timer, with no shimmer. Under prefers-reduced-motion the word is +// static ("Thinking…"); the elapsed counter still advances regardless (it's +// information, not decoration). Timers clear onCleanup so a finished turn stops +// ticking. const WORD_MS = 2000 const TICK_MS = 1000 @@ -45,11 +48,11 @@ export function ThinkingLine(props: { return ( + {word()}… {formatElapsed(elapsedMs())} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 77ab570497..107a625949 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -726,7 +726,9 @@ export function AssistantParts(props: {
- + {/* static: AmicoWave in the thinking line carries the motion now — two + animated brand marks side by side compete with each other */} +
From ca2437cc8b5ccc0ac68f5d2f7399a42eba822819 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 13:56:09 -0400 Subject: [PATCH 09/13] fix(fork): drop TextShimmer from tool-status-title, keep the word morph (cherry picked from commit f2090dd8b1e068bfb635f7b02dac5043db9a8764) --- AMICODE-PATCHES.md | 6 ++++++ packages/ui/src/components/tool-status-title.tsx | 12 +++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index c098e2dcf0..53d964a514 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -353,3 +353,9 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - STILL UNVERIFIED (needs live API creds): whether the OLD legacy `thinking:{type:"enabled",budgetTokens}` form actually 400s against Opus 5 or merely degrades — i.e. whether this was "hobbled" or "unusable". Also unexercised by CI: the Grok/GLM effort variants and the opus-4-5 `budgetTokens`+`effort` combination. - FUTURE SYNC COST: this is a knowing trade — porting file end-states now makes a future clean upstream merge harder on these 4 files. Accepted. NOTE the bug class recurs every model generation (version-regex parsing of model IDs will break again at Opus 6), which argues for a standing narrow sync lane on `transform.ts` rather than one-off unfreezes. - SEPARATE RISK SPOTTED (not fixed here): the release build does a bare `fetch(models.dev/api.json)` with no fallback, so a models.dev outage hard-fails the build. Consider pinning `MODELS_DEV_API_JSON` for the hackathon build. +24. (harmonic wave indicator — de-shimmer the tool-status title, 2026-07-28) — amicode: the chat had two "working" indicators that read as identical — the fork's thinking line and the STOCK tool-group header (`packages/ui/src/components/tool-status-title.tsx`, e.g. "Working in shell" → "Worked in shell") — because both were shimmering text with no glyph. Earlier steps of this run gave the thinking line a standing-wave glyph (`AmicoWave`, `packages/ui/src/amicode/amico-wave.tsx`) and deleted the `amc-text-shimmer` CSS; this patch removes the shimmer from the last surface still asking for it. + - `tool-status-title.tsx`: all five `` render positions (swap-mode active/done, suffix-mode prefix/active/done) replaced with plain `{text}`. `TextShimmer` import removed. The now-orphaned `prefixLen` memo (it existed only to feed the shimmer's `offset` phase-alignment prop) removed too — confirmed via `oxlint` before/after (498→499→498 warnings) that leaving it in place tripped `no-unused-vars`, so dropping it was the correct call rather than "keep everything." + - Deliberately PRESERVED: the `common()` prefix-splitting logic, the width-morph animation (`animate()`/`finish()`, the `requestAnimationFrame`, the stored `width`, the `data-ready` flag), the `data-component`/`data-active`/`data-mode` attributes, the `aria-label`, and the active→done text swap itself. None of that is the shimmer — it's the genuinely good word-morph behavior. + - `text-shimmer.tsx` untouched — it has many other live callers (`basic-tool.tsx`, `message-part.tsx` ×7, `session-turn.tsx`, `v2/components/basic-tool-v2.tsx` via `text-shimmer-v2.tsx`, plus stories), confirmed via `rg -n 'TextShimmer' packages/`. Not a candidate for deletion. + - The dropped `offset` prop existed only so the shimmer's gradient phase stayed continuous across the prefix/tail split; with no shimmer there's nothing to phase-align, so it has no replacement. Verified live (Storybook, `UI/AnimatedCountList` stories) that the prefix and tail still read as one unbroken word with no seam: in the swap-mode `Playground` story, driving an active→done transition showed the mid-animation frame rendering both the active and done spans simultaneously under the animating width (`data-ready="true"`, container `style="width: 0px"` mid-transition, then settling) — the word-morph is intact. The suffix/prefix-tail mode (`data-mode="suffix"`) is currently unreachable from any real call site — both app usages (`message-part.tsx` context-tool-group and shell-group titles) and the only story pass `split={false}` — so it was verified by transiently flipping one story's `split` prop off (`Done` export, "Exploring"/"Explored"), confirming `data-mode="suffix"` renders `"Explor"` + `"ed"` as an unbroken "Explored" with no visible gap, then reverting that story edit before commit (`git status` shows only the two files below). + - Tests: ui `bun test src` → 402 pass / 0 fail (unchanged). typecheck (tsgo) clean in `packages/ui`. `oxlint packages/ui/src` → 498 warnings / 0 errors both before and after (no new warnings once `prefixLen` was dropped). diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 5c46593f71..ccb157d5c7 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,6 +1,5 @@ import { Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { TextShimmer } from "./text-shimmer" function common(active: string, done: string) { const a = Array.from(active) @@ -30,7 +29,6 @@ export function ToolStatusTitle(props: { const suffix = createMemo( () => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0, ) - const prefixLen = createMemo(() => Array.from(split().prefix).length) const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) @@ -102,12 +100,12 @@ export function ToolStatusTitle(props: { - + {activeTail()} - + {doneTail()} @@ -115,17 +113,17 @@ export function ToolStatusTitle(props: { > - + {split().prefix} - + {activeTail()} - + {doneTail()} From b6d150cdfea970c3baaffd0976041657a737e607 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 14:46:05 -0400 Subject: [PATCH 10/13] feat(amicode): collapse consecutive identical receipt cards into one with a count Four amicode_* calls against the same entity rendered four identical cards. Runs of consecutive receipts sharing (problem, entity, action) now render as one card with a count, opening the latest seq. Conservative by design: a differing action, entity or problem, an interrupting part, or an unparseable sentinel all prevent merging, so no state change can be hidden. (cherry picked from commit 5176a69f476591666f273647d417cb396cc7f8ed) --- packages/ui/src/amicode/amicode.css | 6 + packages/ui/src/amicode/card.stories.tsx | 106 ++++++++++++++ packages/ui/src/amicode/card.tsx | 25 +++- packages/ui/src/amicode/receipt-runs.test.ts | 143 +++++++++++++++++++ packages/ui/src/amicode/receipt-runs.ts | 116 +++++++++++++++ packages/ui/src/amicode/receipt.ts | 8 ++ packages/ui/src/components/message-part.tsx | 94 +++++++++++- 7 files changed, 490 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/amicode/card.stories.tsx create mode 100644 packages/ui/src/amicode/receipt-runs.test.ts create mode 100644 packages/ui/src/amicode/receipt-runs.ts diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 29c3caf6a8..8767216b5e 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -222,6 +222,12 @@ [data-component="amicode-card"][data-state="error"] .amc-detail { color: var(--v2-state-fg-danger); } +/* a collapsed run's count (receipt-runs.ts) — short and always-visible, so it + never loses the ellipsis race against a long diff value beside it */ +[data-component="amicode-card"] .amc-count { + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} /* structured diff pieces */ [data-component="amicode-card"] .amc-diff { diff --git a/packages/ui/src/amicode/card.stories.tsx b/packages/ui/src/amicode/card.stories.tsx new file mode 100644 index 0000000000..924ff70178 --- /dev/null +++ b/packages/ui/src/amicode/card.stories.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck +// Visual check for the receipt-run collapse (../receipt-runs.ts): a run of +// consecutive amicode_* receipts sharing (problem, entity, action) renders as +// ONE card with a count instead of N identical-looking ones. Real user report: +// "these repeated amico cards add a lot of clutter can we avoid this?" — see +// message-part.tsx's collapseAmicodeGroups for the wiring; this story exercises +// AmicodeToolCard directly (the count prop it now accepts) rather than the full +// message list, so the collapsed card's rendering is reviewable in isolation. +import { AmicodeToolCard } from "./card" + +export default { + title: "Amicode/ToolCard", + id: "amicode-tool-card", + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: `### Collapsed receipt runs + +Four \`amicode_*\` calls that update the same entity via the same action used to +render four visually-identical cards. A run of consecutive receipts sharing +(problem, entity, action) now collapses into one card carrying a \`×N\` count, +opening the latest (highest-seq) member. A run of one renders exactly as +before — no count shown. A differing action, entity, or problem never +collapses, so a state change is never hidden.`, + }, + }, + }, +} + +const sentinel = (over: Record) => + `AMICODE_DIFF ${JSON.stringify({ problem: "x-gate", entity: "recommend", action: "proposed", seq: 1, diff: {}, ...over })}` + +const Row = (props: { label: string; children: any }) => ( +
+ + {props.label} + +
+ {props.children} +
+
+) + +// A run of 4 identical (problem, entity, action) receipts — what the bug +// report showed as four stacked cards — now collapses to one, count 4, +// opening seq 4 (the latest). +export const CollapsedRunOfFour = () => ( + + + +) + +// A run of one renders exactly as it always has — no count, no markup change. +export const SingleReceipt = () => ( + + + +) + +// Two adjacent runs whose action differs never merge with each other — each +// renders as its own card (here, each itself a collapsed run of 2). +export const AdjacentRunsDifferentActions = () => ( + + + + +) + +// Side-by-side comparison, matching the report's stacked-card shape but with +// the fix applied: the repeated "Recommend" run collapses; the differently- +// actioned "Formulation" card beside it (a run of 1) is untouched. +export const StackedTranscriptExample = () => ( +
+ + +
+) diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index 81a5190442..6556c32516 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -2,7 +2,7 @@ import { For, Match, Show, Switch, createMemo } from "solid-js" import { amicodeStage } from "./stage" import { parseAskInput } from "./ask" import { AmicodeAskCard } from "./ask-card" -import { parseDiffSentinel, receiptParts } from "./receipt" +import { parseDiffSentinel, receiptParts, INLINE_KINDS } from "./receipt" import { receiptIsCurrent } from "./receipt-currency" import { systemReceiptPieces, formulationReceiptPieces } from "./facets" import { compositeChip, chipText } from "./problem" @@ -55,7 +55,7 @@ function runRefFromOutput(output: unknown): { run: string; lab?: string } | unde type DiffPiece = { key: string; from?: string; to?: string } -function Chip(props: { tool: string; status?: string; output?: string }) { +function Chip(props: { tool: string; status?: string; output?: string; count?: number }) { const stage = createMemo(() => amicodeStage(props.tool)) const running = () => props.status === "pending" || props.status === "running" const errored = () => props.status === "error" || props.status === "failed" @@ -179,6 +179,15 @@ function Chip(props: { tool: string; status?: string; output?: string }) { )}
+ {/* amicode: a run of N consecutive receipts sharing (problem, entity, + action) collapses to this one card (../components/message-part.tsx, + ../amicode/receipt-runs.ts) — shown only when N > 1 so a lone + receipt renders exactly as it always has. */} + 1}> + + ×{props.count} + +
)} @@ -253,8 +262,6 @@ function Chip(props: { tool: string; status?: string; output?: string }) { // switching problems mid-chat retroactively rewrote earlier receipts. Only the // CURRENT receipt for the ACTIVE problem may render live (./receipt-currency.ts); // the rest fall through to the Chip, which renders from their own captured diff. -const INLINE_KINDS = new Set(["system", "formulation", "run", "device_session", "calibration"]) - function InlineEntityView(props: { kind: string; seq?: number }) { const labels = createMemo(() => amicodeEntityLabels()) return ( @@ -280,6 +287,14 @@ export function AmicodeToolCard(props: { output?: string // passed by message-part.tsx's Dynamic (already wired) messageID?: string sessionID?: string + // amicode: set by message-part.tsx when this part is the surviving (latest) + // member of a collapsed run of ≥2 identical (problem, entity, action) + // receipts (../receipt-runs.ts). Only the Chip fallback below reads it — + // every collapse-eligible receipt renders through Chip by construction + // (receipt-runs.ts excludes anything that could route elsewhere), so a + // count reaching ask/approval/run/widget/inline-entity render is not + // expected; those paths simply ignore the prop. + count?: number }) { const ask = createMemo(() => (props.tool === "amicode_ask" ? parseAskInput(props.input) : undefined)) const runRef = createMemo(() => (props.tool === "amicode_solve" ? runRefFromOutput(props.output) : undefined)) @@ -309,7 +324,7 @@ export function AmicodeToolCard(props: { }) return ( - }> + }> {(value) => } diff --git a/packages/ui/src/amicode/receipt-runs.test.ts b/packages/ui/src/amicode/receipt-runs.test.ts new file mode 100644 index 0000000000..01fd87ac12 --- /dev/null +++ b/packages/ui/src/amicode/receipt-runs.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test" +import { collapseReceiptRuns, receiptRunKey, type ReceiptCandidate } from "./receipt-runs" +import { parseDiffSentinel } from "./receipt" + +const sentinel = (over: Partial> = {}) => + parseDiffSentinel( + `AMICODE_DIFF ${JSON.stringify({ + problem: "x-gate", + entity: "recommend", + action: "proposed", + seq: 1, + diff: {}, + ...over, + })}`, + ) + +describe("receiptRunKey", () => { + test("a parsed sentinel for a non-inline entity yields its (problem, entity, action)", () => { + expect(receiptRunKey(sentinel())).toEqual({ problem: "x-gate", entity: "recommend", action: "proposed" }) + }) + + test("undefined sentinel (unparseable output) → undefined", () => { + expect(receiptRunKey(undefined)).toBeUndefined() + }) + + // INLINE_KINDS entities may resolve to the live InlineEntityView depending on + // receipt-currency's reactive currency check — this pure module can't see that, + // so it refuses to key them at all rather than risk collapsing across it. + test("an INLINE_KINDS entity (system/formulation/run/device_session/calibration) → undefined", () => { + expect(receiptRunKey(sentinel({ entity: "system" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "formulation" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "run" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "device_session" }))).toBeUndefined() + expect(receiptRunKey(sentinel({ entity: "calibration" }))).toBeUndefined() + }) +}) + +// Candidates below use plain string refs ("a", "b", …) standing in for the +// PartGroup entries message-part.tsx actually passes. +const key = (entity: string, action = "proposed", problem = "x-gate") => ({ problem, entity, action }) + +describe("collapseReceiptRuns", () => { + test("empty input → empty output", () => { + expect(collapseReceiptRuns([])).toEqual([]) + }) + + test("a run of 4 identical (problem, entity, action) → one entry, count 4, latest = max seq", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("recommend"), seq: 2 }, + { ref: "c", key: key("recommend"), seq: 3 }, + { ref: "d", key: key("recommend"), seq: 4 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0]).toEqual({ refs: ["a", "b", "c", "d"], latestRef: "d", count: 4 }) + }) + + test("does not assume seqs are sorted — the highest numeric seq wins regardless of position", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 3 }, + { ref: "b", key: key("recommend"), seq: 1 }, + { ref: "c", key: key("recommend"), seq: 9 }, + { ref: "d", key: key("recommend"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0].latestRef).toBe("c") + expect(runs[0].count).toBe(4) + }) + + test("all members missing seq → latest falls back to the LAST member (transcript order)", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend") }, + { ref: "b", key: key("recommend") }, + { ref: "c", key: key("recommend") }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(1) + expect(runs[0].latestRef).toBe("c") + }) + + test("differing action → two entries, counts 1 and 1 (a state change must not hide)", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend", "proposed"), seq: 1 }, + { ref: "b", key: key("recommend", "gated"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs[0]).toEqual({ refs: ["a"], latestRef: "a", count: 1 }) + expect(runs[1]).toEqual({ refs: ["b"], latestRef: "b", count: 1 }) + }) + + test("differing entity → not merged", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("problem"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs.map((r) => r.count)).toEqual([1, 1]) + }) + + test("differing problem → not merged", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend", "proposed", "x-gate"), seq: 1 }, + { ref: "b", key: key("recommend", "proposed", "y-gate"), seq: 2 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs.map((r) => r.count)).toEqual([1, 1]) + }) + + test("a non-mergeable part interrupting a run → two separate runs either side of it", () => { + const items: ReceiptCandidate[] = [ + { ref: "a", key: key("recommend"), seq: 1 }, + { ref: "b", key: key("recommend"), seq: 2 }, + { ref: "other" }, // e.g. a text part, a different tool, an INLINE_KINDS receipt + { ref: "c", key: key("recommend"), seq: 3 }, + { ref: "d", key: key("recommend"), seq: 4 }, + ] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(3) + expect(runs[0]).toEqual({ refs: ["a", "b"], latestRef: "b", count: 2 }) + expect(runs[1]).toEqual({ refs: ["other"], latestRef: "other", count: 1 }) + expect(runs[2]).toEqual({ refs: ["c", "d"], latestRef: "d", count: 2 }) + }) + + test("a single card → count 1, shape unchanged from the uncollapsed case", () => { + const runs = collapseReceiptRuns([{ ref: "solo", key: key("recommend"), seq: 1 }]) + expect(runs).toEqual([{ refs: ["solo"], latestRef: "solo", count: 1 }]) + }) + + test("unparseable sentinel (no key) → never merged, not even with an adjacent identical unparseable one", () => { + const items: ReceiptCandidate[] = [{ ref: "a" }, { ref: "b" }] + const runs = collapseReceiptRuns(items) + expect(runs).toHaveLength(2) + expect(runs).toEqual([ + { refs: ["a"], latestRef: "a", count: 1 }, + { refs: ["b"], latestRef: "b", count: 1 }, + ]) + }) +}) diff --git a/packages/ui/src/amicode/receipt-runs.ts b/packages/ui/src/amicode/receipt-runs.ts new file mode 100644 index 0000000000..a66a9221e9 --- /dev/null +++ b/packages/ui/src/amicode/receipt-runs.ts @@ -0,0 +1,116 @@ +// AMICODE: collapses RUNS of consecutive amicode_* receipt cards that share +// (problem, entity, action) into one card carrying a count — the fix for a +// real user report: "these repeated amico cards add a lot of clutter can we +// avoid this?" Four amicode_* calls that all update the same entity via the +// same action render four visually-identical cards; amico-presence.ts's +// design intent is that Amico pops in, works, and pops out clean — a wall of +// look-alike receipts is exactly the clutter that breaks. +// +// Pure + DOM-free (fork convention — see thinking.ts, run-series.ts, +// receipt-currency.ts): this module knows nothing about SDK part/message +// types. It works over an abstract `Ref` — message-part.tsx supplies +// PartGroup entries as Refs and reduces the result back into its render list. +// +// Conservative by construction, per the spec's rules: +// - Only a PARSED AMICODE_DIFF sentinel carries (problem, entity, action). +// A receipt whose sentinel doesn't parse (the legacy Chip fallback — +// receipt.ts's parseDiffSentinel, card.tsx's Chip) has no key and never +// merges with anything, including another unparseable receipt. +// - INLINE_KINDS entities (receipt.ts) never merge either, even though +// their sentinel parses fine. Whether such a receipt renders as a Chip or +// the live InlineEntityView depends on receipt-currency.ts's +// receiptIsCurrent, which reads the LIVE problem view — reactive +// UI-bridge state this pure module has no access to. Collapsing one of +// those into a single representative card risks silently dropping +// whichever render path the representative doesn't take, which would be +// exactly the information loss this feature must not cause. Left to +// render exactly as before; only entities that always take the Chip body +// (e.g. "recommend", "problem") are collapse-eligible. +// - "Latest" reuses receipt-currency's seq semantics — highest numeric seq +// wins, order not assumed (see latestSeqForEntity) — rather than +// inventing a parallel notion of "current". A collapsed card must open +// the highest seq in its run because later receipts supersede earlier +// ones. + +import { INLINE_KINDS, type DiffSentinel } from "./receipt" + +export interface ReceiptKey { + problem: string + entity: string + action: string +} + +function sameKey(a: ReceiptKey, b: ReceiptKey): boolean { + return a.problem === b.problem && a.entity === b.entity && a.action === b.action +} + +/** The (problem, entity, action) a receipt would merge on, or undefined when + * it must never merge: no sentinel parsed, or its entity has a live inline + * view this module can't safely predict the routing for (see module docs). */ +export function receiptRunKey(sentinel: DiffSentinel | undefined): ReceiptKey | undefined { + if (!sentinel) return undefined + if (INLINE_KINDS.has(sentinel.entity)) return undefined + return { problem: sentinel.problem, entity: sentinel.entity, action: sentinel.action } +} + +export interface ReceiptCandidate { + ref: Ref + /** undefined ⇒ this candidate can never merge, with anything. */ + key?: ReceiptKey + seq?: number +} + +export interface ReceiptRun { + /** Members in transcript order. Length 1 for a non-merged (or unmergeable) receipt. */ + refs: Ref[] + /** The member to render: highest seq; ties or all-missing seq prefer the LATER member. */ + latestRef: Ref + count: number +} + +/** Highest-seq member of a run. Ties, or a run where no member carries a + * seq, prefer the member that occurs LATER in the run — transcript order is + * itself a proxy for recency when the sentinel omits `seq`. Mirrors + * receipt-currency's latestSeqForEntity (max wins, order not assumed) + * without requiring every member to carry a seq the way EventView does. */ +function pickLatest(items: ReceiptCandidate[]): Ref { + let best = items[0] + for (let i = 1; i < items.length; i++) { + const item = items[i] + if (item.seq !== undefined && (best.seq === undefined || item.seq > best.seq)) best = item + else if (item.seq === undefined && best.seq === undefined) best = item + } + return best.ref +} + +/** Collapse consecutive candidates that share a key into one run. A + * candidate with no key never merges with its neighbours — not even with + * another keyless candidate — so it always surfaces as its own run of one, + * and it breaks any run adjacent to it. */ +export function collapseReceiptRuns(items: ReceiptCandidate[]): ReceiptRun[] { + const runs: ReceiptRun[] = [] + let open: ReceiptCandidate[] = [] + + const flushOpen = () => { + if (open.length === 0) return + runs.push({ refs: open.map((item) => item.ref), latestRef: pickLatest(open), count: open.length }) + open = [] + } + + for (const item of items) { + if (!item.key) { + flushOpen() + runs.push({ refs: [item.ref], latestRef: item.ref, count: 1 }) + continue + } + const current = open[open.length - 1] + if (current?.key && sameKey(current.key, item.key)) open.push(item) + else { + flushOpen() + open.push(item) + } + } + flushOpen() + + return runs +} diff --git a/packages/ui/src/amicode/receipt.ts b/packages/ui/src/amicode/receipt.ts index de56e598f1..b974d4789d 100644 --- a/packages/ui/src/amicode/receipt.ts +++ b/packages/ui/src/amicode/receipt.ts @@ -42,6 +42,14 @@ export function parseDiffSentinel(output: unknown): DiffSentinel | undefined { } } +// Entities with a live in-transcript view (card.tsx's InlineEntityView, fed by +// the entity rail) instead of just a diff chip. Whether any GIVEN receipt for +// one of these renders as the chip or the live view depends on +// receipt-currency.ts's receiptIsCurrent (reactive, live-problem-view state) — +// so anything keying off this set (e.g. receipt-runs.ts) must treat it as "no +// safe static answer" rather than guessing. +export const INLINE_KINDS = new Set(["system", "formulation", "run", "device_session", "calibration"]) + export const ENTITY_LABELS: Record = { system: "System", formulation: "Formulation", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 107a625949..d4559ab6c8 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -202,6 +202,11 @@ export interface MessagePartProps { virtualizeDiff?: boolean showAssistantCopyPartID?: string | null turnDurationMs?: number + // amicode: set when this part is the surviving (latest) member of a + // collapsed run of ≥2 identical (problem, entity, action) receipts + // (./message-part-groups's PartGroup + ../amicode/receipt-runs.ts). Only + // AmicodeToolCard reads it; every other part type ignores it. + count?: number } export type PartComponent = Component @@ -545,12 +550,65 @@ export { type PartGroup, type PartRef, } from "./message-part-groups" -import { groupParts, sameGroups, isContextGroupTool, isShellGroupTool, type PartGroup } from "./message-part-groups" +import { + groupParts, + sameGroups, + isContextGroupTool, + isShellGroupTool, + type PartGroup, + type PartRef, +} from "./message-part-groups" +import { parseDiffSentinel } from "../amicode/receipt" +import { collapseReceiptRuns, receiptRunKey, type ReceiptCandidate, type ReceiptKey } from "../amicode/receipt-runs" function index(items: readonly T[]) { return new Map(items.map((item) => [item.id, item] as const)) } +// amicode: does this resolved part carry a mergeable receipt key? Only +// completed amicode_* tool calls whose AMICODE_DIFF sentinel parses (and +// whose entity isn't inline-view-eligible — see receipt-runs.ts) are +// candidates; everything else (still running, errored, not amicode_*, no/ +// unparseable sentinel) gets `key: undefined` and can never merge. +function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { + if (!part || part.type !== "tool" || !part.tool.startsWith("amicode_")) return {} + if (part.state.status !== "completed") return {} + const sentinel = parseDiffSentinel(part.state.output) + return { key: receiptRunKey(sentinel), seq: sentinel?.seq } +} + +function sameAmicodeCounts(a: Map, b: Map) { + if (a === b) return true + if (a.size !== b.size) return false + for (const [k, v] of a) if (b.get(k) !== v) return false + return true +} + +// amicode: second pass over groupParts's output that collapses consecutive +// "part" entries which are amicode_* receipts sharing (problem, entity, +// action) into one — the fix for "these repeated amico cards add a lot of +// clutter" (four amicode_* calls updating the same entity rendering four +// identical cards). context/shell groups and any part that isn't a +// collapse-eligible receipt pass through unchanged; see ../amicode/ +// receipt-runs.ts for the (conservative, tested) matching rules. +function collapseAmicodeGroups( + groups: readonly PartGroup[], + resolvePart: (ref: PartRef) => PartType | undefined, +): { groups: PartGroup[]; counts: Map } { + const candidates: ReceiptCandidate[] = groups.map((group) => { + if (group.type !== "part") return { ref: group } + const { key, seq } = amicodeReceiptCandidateKey(resolvePart(group.ref)) + return { ref: group, key, seq } + }) + const runs = collapseReceiptRuns(candidates) + const counts = new Map() + const survivors = runs.map((run) => { + if (run.count > 1) counts.set(run.latestRef.key, run.count) + return run.latestRef + }) + return { groups: survivors, counts } +} + export function renderable(part: PartType, showReasoningSummaries = true) { if (part.type === "tool") { if (HIDDEN_TOOLS.has(part.tool)) return false @@ -610,6 +668,18 @@ export function AssistantParts(props: { const last = createMemo(() => grouped().at(-1)?.key) + // amicode: collapse runs of consecutive amicode_* receipts sharing (problem, + // entity, action) into one card + count (../amicode/receipt-runs.ts). Drives + // the render list below; `last()` above stays keyed off the UNCOLLAPSED + // groups so the busy/streaming indicator keeps comparing against the raw + // most-recent group (its key survives collapsing whenever it's genuinely the + // latest receipt, which is the only case that indicator cares about). + const collapsed = createMemo( + () => collapseAmicodeGroups(grouped(), (ref) => part().get(ref.messageID)?.get(ref.partID)), + { groups: [] as PartGroup[], counts: new Map() }, + { equals: (a, b) => sameGroups(a.groups, b.groups) && sameAmicodeCounts(a.counts, b.counts) }, + ) + // amicode: tokens generated so far this turn (output + reasoning across the // turn's assistant messages) — feeds the thinking line's live token chip. // Reactive: re-runs as the store updates message.tokens.* while streaming. @@ -636,7 +706,7 @@ export function AssistantParts(props: { in-domain turn, Amico's working presence rides in an offset accent lane BELOW the streamed parts (see the lane after ). spec-20260712-amico-third-actor. */} - + {(entryAccessor) => { const entryType = createMemo(() => entryAccessor().type) @@ -698,6 +768,9 @@ export function AssistantParts(props: { if (entry.type !== "part") return return part().get(entry.ref.messageID)?.get(entry.ref.partID) }) + // amicode: >1 when this part survived a receipt-run collapse + // (../amicode/receipt-runs.ts); undefined for every other part. + const count = createMemo(() => collapsed().counts.get(entryAccessor().key)) return ( @@ -708,6 +781,7 @@ export function AssistantParts(props: { showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} + count={count()} /> @@ -893,8 +967,16 @@ export function AssistantMessageDisplay(props: { { equals: sameGroups }, ) + // amicode: same receipt-run collapse as AssistantParts, above — see + // ../amicode/receipt-runs.ts. + const collapsed = createMemo( + () => collapseAmicodeGroups(grouped(), (ref) => part().get(ref.partID)), + { groups: [] as PartGroup[], counts: new Map() }, + { equals: (a, b) => sameGroups(a.groups, b.groups) && sameAmicodeCounts(a.counts, b.counts) }, + ) + return ( - + {(entryAccessor) => { const entryType = createMemo(() => entryAccessor().type) @@ -949,6 +1031,7 @@ export function AssistantMessageDisplay(props: { if (entry.type !== "part") return return part().get(entry.ref.partID) }) + const count = createMemo(() => collapsed().counts.get(entryAccessor().key)) return ( @@ -956,6 +1039,7 @@ export function AssistantMessageDisplay(props: { part={item()!} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} + count={count()} /> ) @@ -1404,6 +1488,7 @@ export function Part(props: MessagePartProps) { virtualizeDiff={props.virtualizeDiff} showAssistantCopyPartID={props.showAssistantCopyPartID} turnDurationMs={props.turnDurationMs} + count={props.count} /> ) @@ -1579,6 +1664,9 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined} deferContent={props.deferToolContent} virtualizeDiff={props.virtualizeDiff} + // amicode: collapsed-run count (../amicode/receipt-runs.ts); only + // AmicodeToolCard reads this, every other tool component ignores it + count={props.count} /> From 68aca7b0fcdbaef60cf8868588fc73c563855e86 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 14:50:19 -0400 Subject: [PATCH 11/13] fix(amicode): say 'Skill' when a skill activates, and stop it shimmering An activated skill rendered as a bare name ('brainstorming'), indistinguishable from any other tool call: ui.tool.skill ('Skill') was reachable only as a fallback for a missing input.name, which never happens. The kind now always renders in front of the name, in both the expandable trigger and the compact title map. Also removes the last TextShimmer in the skill renderer. The previous commit retired the shimmer from tool-status-title but missed this site, leaving the two inconsistent. Not made an amicode receipt card, despite the temptation: those carry an AMICODE_DIFF sentinel and open an entity view. A skill has neither, and spending the H-mark card on generic agent mechanics would dilute what the mark means. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit b6981f6789aea09bd4f2fb51d5770c21303cdf57) --- packages/ui/src/amicode/amicode.css | 20 +++++++++++++++++ packages/ui/src/components/message-part.tsx | 24 +++++++++++++++------ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 8767216b5e..ec20c3bf55 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -82,6 +82,26 @@ /* The glyph is a block, not text — baseline alignment would sit it high. */ .amc-thinking .amc-wave { align-self: center; } +/* ---- skill activation label (message-part.tsx, ToolRegistry "skill") ----- */ +/* An activated skill used to render as a bare name ("brainstorming"), because + * `ui.tool.skill` was reachable only as a fallback for a missing input.name — which never + * happens. The kind now always renders in front of the name. + * + * Deliberately NOT an amicode receipt card: those carry an AMICODE_DIFF sentinel + * ({problem, entity, action, seq, diff}) and clicking one opens an entity view. A skill has + * none of that, and spending the H-mark card on generic agent mechanics would dilute what the + * mark means — it should say "Amico did something in your problem domain", not "something + * happened". BasicTool already renders a `brain` icon for skills, which carries the kind + * visually; this supplies the word. */ +.amc-skill-kind { + margin-right: 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--v2-text-text-muted); +} + /* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */ /* A standing wave in quadrature: two identical curves, the companion a quarter period out * of phase, so one is at full swing whenever the other crosses zero. diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d4559ab6c8..e0493a4453 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -472,7 +472,9 @@ export function getToolInfo( case "skill": return { icon: "brain", - title: input.name || i18n.t("ui.tool.skill"), + // AMICODE: name the kind here too. This is the compact/summary path, where the + // fallback-only use of ui.tool.skill left an activated skill looking like a bare tool. + title: input.name ? `${i18n.t("ui.tool.skill")} · ${input.name}` : i18n.t("ui.tool.skill"), } default: return { @@ -2622,17 +2624,25 @@ ToolRegistry.register({ name: "skill", render(props) { const i18n = useI18n() - const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill")) - const running = createMemo(() => props.status === "pending" || props.status === "running") + // AMICODE: label the kind, then name it. `ui.tool.skill` used to be reachable only as a + // fallback for a missing input.name — which never happens — so an activated skill rendered + // as a bare word ("brainstorming") indistinguishable from any other tool. The kind now + // always renders, with the skill's own name beside it. + const name = createMemo(() => props.input.name?.trim() || "") const body = createMemo(() => skillBody(props.output)) - const titleContent = () => - const trigger = () => (
- - {titleContent()} + + + {i18n.t("ui.tool.skill")} + + + + {name()} + +
From 7d45579dad1aa53349f6746f0bf09f6d24986ecf Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 28 Jul 2026 15:01:16 -0400 Subject: [PATCH 12/13] feat(amicode): render skill activations as an Amico chip Amicode's skills are Amico's own repertoire, so activating one is Amico acting and earns the same chip the domain receipts wear: H-mark, rule, label naming the kind, detail naming the skill. Replaces the plain text label from the previous commit. New AmicoSkillChip in card.tsx rather than a new mode on Chip -- Chip is sentinel-driven and a skill has no AMICODE_DIFF, so threading it through would have meant special-casing the parse path. Inert shell, no chevron: there is no entity to open. It nests inside BasicTool's trigger so the expandable instruction body is kept. That is only safe because BasicTool declares an `icon` prop and never renders it -- verified, nothing in basic-tool.tsx reads props.icon -- so the H-mark is the row's only glyph and no stock component needed changing. Co-Authored-By: Claude Opus 5 (1M context) (cherry picked from commit 17c67555f3a3155c90094329c9af514b59b1c50c) --- packages/ui/src/amicode/amicode.css | 20 -------- packages/ui/src/amicode/card.tsx | 57 +++++++++++++++++++++ packages/ui/src/components/message-part.tsx | 13 +---- 3 files changed, 59 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index ec20c3bf55..8767216b5e 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -82,26 +82,6 @@ /* The glyph is a block, not text — baseline alignment would sit it high. */ .amc-thinking .amc-wave { align-self: center; } -/* ---- skill activation label (message-part.tsx, ToolRegistry "skill") ----- */ -/* An activated skill used to render as a bare name ("brainstorming"), because - * `ui.tool.skill` was reachable only as a fallback for a missing input.name — which never - * happens. The kind now always renders in front of the name. - * - * Deliberately NOT an amicode receipt card: those carry an AMICODE_DIFF sentinel - * ({problem, entity, action, seq, diff}) and clicking one opens an entity view. A skill has - * none of that, and spending the H-mark card on generic agent mechanics would dilute what the - * mark means — it should say "Amico did something in your problem domain", not "something - * happened". BasicTool already renders a `brain` icon for skills, which carries the kind - * visually; this supplies the word. */ -.amc-skill-kind { - margin-right: 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.09em; - text-transform: uppercase; - color: var(--v2-text-text-muted); -} - /* ---- the harmonic working indicator (amico-wave.tsx) --------------------- */ /* A standing wave in quadrature: two identical curves, the companion a quarter period out * of phase, so one is at full swing whenever the other crosses zero. diff --git a/packages/ui/src/amicode/card.tsx b/packages/ui/src/amicode/card.tsx index 6556c32516..f2fc415a6a 100644 --- a/packages/ui/src/amicode/card.tsx +++ b/packages/ui/src/amicode/card.tsx @@ -248,6 +248,63 @@ function Chip(props: { tool: string; status?: string; output?: string; count?: n ) } +// AMICODE: a skill activation, wearing the Amico chip. Amicode's skills ARE Amico's — its +// repertoire — so activating one is Amico acting, and it earns the same chip the domain +// receipts wear. Reads " " like every other chip: the label names the kind, +// the detail names the skill. +// +// Inert by construction. Unlike a receipt there is no entity to open, so this is the plain +// shell with no chevron rather than the clickable