From 2c8470ca298251b52c1b5b14af725738e7231762 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 23 Aug 2026 12:10:07 -0400 Subject: [PATCH 1/2] fix(theme): dark mode goes back to the stock neutral ground and text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brand re-theme (#235) carried the site's cream/olive palette into the dark variant as well as the light one. Cream reads as a deliberate warm surface on white; on a dark ground it reads as a colour cast, and next to the surrounding VS Code chrome the app looked like it belonged to a different product. Dark backgrounds, text, icons and borders return to the values they had before the re-theme (oc-2's neutral ramp) — greys 50-1200, the text and icon steps that ride on them, the legacy surface/text/border overrides, and palette.neutral/ink, which seed everything the overrides do not pin. The pre-paint literal and the terminal's default dark colours follow, so the first frame and the terminal surface match the app again. The brand is untouched: yellow accent (#FFE614) in every accent role, the semantic green/red/warning ramps, the status dots, the yellow selection fill, and the whole light variant stay exactly as merged. Verified by resolving both variants — every neutral background/text/border/icon token is now byte-identical to oc-2, and every remaining difference is an accent- or status-derived token. --- packages/app/public/oc-theme-preload.js | 11 +-- packages/app/src/components/terminal.tsx | 8 +-- packages/ui/src/theme/themes/harmoniqs.json | 74 ++++++++++----------- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index 0fe9a1b60..a1e6b5277 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -37,14 +37,15 @@ document.documentElement.dataset.theme = themeId document.documentElement.dataset.colorScheme = mode - // Brand ground, tracking harmoniqs.json palette.neutral (dark #000, light #fff). - // This paints before any stylesheet, so a stock literal here shows through as - // the old brand for the first frame. - document.documentElement.style.backgroundColor = isDark ? "#0F0F0D" : "#ffffff" + // Pre-paint ground, tracking harmoniqs.json's deepest ground per scheme: + // dark keeps the stock neutral #080808 (the brand cream-black read as warm + // against VS Code's chrome), light is the brand white. This paints before any + // stylesheet, so a wrong literal here shows through for the first frame. + document.documentElement.style.backgroundColor = isDark ? "#080808" : "#ffffff" // Update theme-color meta tag to match app color scheme var metas = document.querySelectorAll("meta[name='theme-color']") - if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#0F0F0D" : "#ffffff") + if (metas.length > 0) metas[0].setAttribute("content", isDark ? "#080808" : "#ffffff") if (themeId === "oc-2") return // stock theme needs no cached CSS diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index cd9b93a43..538c629a5 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -59,10 +59,10 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { selectionBackground: withAlpha("#000000", 0.2), }, dark: { - background: "#000000", - foreground: "#EFEDCD", - cursor: "#EFEDCD", - selectionBackground: withAlpha("#EFEDCD", 0.25), + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + selectionBackground: withAlpha("#d4d4d4", 0.25), }, } diff --git a/packages/ui/src/theme/themes/harmoniqs.json b/packages/ui/src/theme/themes/harmoniqs.json index 1b06e62d6..83cf32e58 100644 --- a/packages/ui/src/theme/themes/harmoniqs.json +++ b/packages/ui/src/theme/themes/harmoniqs.json @@ -245,33 +245,33 @@ }, "dark": { "palette": { - "neutral": "#0F0F0D", - "ink": "#EFEDCD", + "neutral": "#1f1f1f", + "ink": "#f1ece8", "primary": "#FFE614", "accent": "#FFE614", "success": "#4ade80", "warning": "#F0D600", "error": "#e88484", - "info": "#C9C7A6", + "info": "#edb2f1", "interactive": "#FFE614", "diffAdd": "#4ade80", "diffDelete": "#e88484" }, "overrides": { - "text-strong": "#EFEDCD", - "text-base": "#C9C7A6", - "text-weak": "#A9A88C", - "text-weaker": "#8a8972", + "text-strong": "#EDEDED", + "text-base": "#A0A0A0", + "text-weak": "#707070", + "text-weaker": "#505050", "text-diff-add-base": "var(--v2-state-fg-success)", "text-diff-delete-base": "var(--v2-state-fg-danger)", - "border-weak-base": "#EFEDCD38", - "border-weaker-base": "#EFEDCD1F", - "icon-base": "#C9C7A6", - "icon-weak-base": "#8a8972", - "surface-raised-base": "#171714", - "surface-raised-base-hover": "#201f1b", - "surface-base": "#0F0F0D", - "surface-base-hover": "#EFEDCD0D", + "border-weak-base": "#282828", + "border-weaker-base": "#232323", + "icon-base": "#7E7E7E", + "icon-weak-base": "#343434", + "surface-raised-base": "#232323", + "surface-raised-base-hover": "#282828", + "surface-base": "#1C1C1C", + "surface-base-hover": "#FFFFFF0D", "surface-interactive-weak": "#2a2600", "surface-success-base": "#052e16", "syntax-comment": "var(--v2-text-text-muted)", @@ -280,7 +280,7 @@ "syntax-primitive": "var(--v2-pink-400)", "syntax-property": "var(--v2-orange-400)", "syntax-type": "var(--v2-purple-400)", - "syntax-constant": "#C9C7A6", + "syntax-constant": "#93e9f6", "syntax-critical": "var(--v2-red-400)", "syntax-diff-add": "var(--v2-state-fg-success)", "syntax-diff-delete": "var(--v2-state-fg-danger)", @@ -288,19 +288,19 @@ "surface-critical-base": "#2a0c0c" }, "v2Overrides": { - "v2-grey-50": "#FFFDF0ff", - "v2-grey-100": "#EFEDCDff", - "v2-grey-200": "#E3E0C0ff", - "v2-grey-300": "#C9C7A6ff", - "v2-grey-400": "#A9A88Cff", - "v2-grey-500": "#8a8972ff", - "v2-grey-600": "#6e6d5aff", - "v2-grey-700": "#545343ff", - "v2-grey-800": "#3a3a30ff", - "v2-grey-900": "#201f1bff", - "v2-grey-1000": "#171714ff", - "v2-grey-1100": "#0F0F0Dff", - "v2-grey-1200": "#000000ff", + "v2-grey-50": "#ffffffff", + "v2-grey-100": "#fafafaff", + "v2-grey-200": "#f2f2f2ff", + "v2-grey-300": "#eeeeeeff", + "v2-grey-400": "#dbdbdbff", + "v2-grey-500": "#aeaeaeff", + "v2-grey-600": "#808080ff", + "v2-grey-700": "#5c5c5cff", + "v2-grey-800": "#3a3a3aff", + "v2-grey-900": "#2e2e2eff", + "v2-grey-1000": "#242424ff", + "v2-grey-1100": "#161616ff", + "v2-grey-1200": "#080808ff", "v2-red-100": "#fde2e2ff", "v2-red-200": "#f6d5d3ff", "v2-red-300": "#fca5a5ff", @@ -407,23 +407,23 @@ "v2-background-bg-contrast": "var(--v2-grey-700)", "v2-background-bg-button-neutral": "var(--v2-alpha-light-6)", "v2-background-bg-accent": "#FFE614", - "v2-text-text-base": "var(--v2-grey-100)", - "v2-text-text-muted": "var(--v2-grey-300)", - "v2-text-text-faint": "var(--v2-grey-400)", + "v2-text-text-base": "var(--v2-grey-200)", + "v2-text-text-muted": "var(--v2-grey-500)", + "v2-text-text-faint": "var(--v2-grey-600)", "v2-text-text-inverse": "var(--v2-grey-1000)", "v2-text-text-contrast": "var(--v2-grey-100)", "v2-text-text-accent": "#FFE614", "v2-text-text-accent-hover": "#F0D600", "v2-text-text-code-accent": "var(--v2-grey-200)", - "v2-icon-icon-base": "var(--v2-grey-300)", - "v2-icon-icon-muted": "var(--v2-grey-400)", + "v2-icon-icon-base": "var(--v2-grey-400)", + "v2-icon-icon-muted": "var(--v2-grey-600)", "v2-icon-icon-inverse": "var(--v2-grey-1000)", "v2-icon-icon-contrast": "var(--v2-grey-200)", "v2-icon-icon-accent": "#FFE614", "v2-icon-icon-accent-hover": "#F0D600", - "v2-border-border-muted": "#EFEDCD1F", - "v2-border-border-base": "#EFEDCD38", - "v2-border-border-strong": "#EFEDCD5C", + "v2-border-border-muted": "var(--v2-alpha-light-8)", + "v2-border-border-base": "var(--v2-alpha-light-10)", + "v2-border-border-strong": "var(--v2-alpha-light-20)", "v2-border-border-inverse": "var(--v2-grey-100)", "v2-border-border-focus": "var(--v2-blue-500)", "v2-overlay-simple-overlay-hover": "var(--v2-alpha-light-6)", From f556b96009439593d3f7037384587963f8bd3ac6 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Sun, 23 Aug 2026 12:11:17 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat(timeline):=20thought=20rail=20?= =?UTF-8?q?=E2=80=94=20a=20spine=20down=20a=20turn's=20assistant=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the vertical rail from the website's /amicode animation (harmoniqs-ai app/components/demo/parts.jsx, `Step`) into the real chat. Until now a five-step solve rendered as five unrelated collapsible rows; the shape of the work was invisible. Two decisions that look odd and are load-bearing. PER-ROW SEGMENTS, not a container spine. The timeline is virtualised (@tanstack/solid-virtual): every row is an absolutely-positioned box and consecutive rows share no ancestor but the total-height spacer, so border-left on a parent is structurally impossible. The site independently arrived at the same per-row approach, which is why this port is small. Segments meet because each row already owns the 12px pt-3 gap the segment spans. DONE IS DECIDED BY ADJACENCY — a step fills once a successor exists — not by that step's own tool lifecycle. Tools complete out of order and run in parallel, so asking each row "are you finished?" would let a filled dot sit above a hollow one and destroy the rail's grammar. Adjacency makes the sequence monotonic by construction, and it is exactly what the website does: a step flips filled no later than the moment the next one appears. Tested as an invariant at several turn lengths: exactly one dot is ever running, and it is always the tail. Hollow therefore means RUNNING, never "planned". Worth recording that the website does not preview future steps either — its scenes gate every entry on `t >= from` and filter the rest out, so an unstarted step is never in the DOM. I had assumed otherwise. Simulating all eight scenes at 10ms resolution: max simultaneous hollow dots = 1, the tail in 100% of frames. Showing the real path ahead needs a plan source — the score `stages:` list is the candidate, and is a separate step. A single-step turn draws nothing: one dot on its own reads as decoration. TimelineRow.AssistantPart gains lastAssistantPart and turnRunning to carry the two facts the rail needs. --- packages/app/src/index.css | 20 +++++ .../session/timeline/message-timeline.tsx | 12 ++- .../pages/session/timeline/projection.test.ts | 2 + .../app/src/pages/session/timeline/rows.ts | 15 +++- .../session/timeline/thought-rail.test.ts | 57 ++++++++++++ .../pages/session/timeline/thought-rail.tsx | 88 +++++++++++++++++++ .../pages/session/timeline/timeline-row.ts | 4 + 7 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/thought-rail.test.ts create mode 100644 packages/app/src/pages/session/timeline/thought-rail.tsx diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4199ff2f7..8aa1b97a6 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -376,3 +376,23 @@ } } +/* The thought rail's live node: hollow and breathing while the step is in + flight, matching the website's rail-dot. Honours reduced motion. */ +[data-slot="thought-rail-dot"].thought-rail-dot--running { + animation: thought-rail-pulse 1.8s ease-in-out infinite; +} +@keyframes thought-rail-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} +@media (prefers-reduced-motion: reduce) { + [data-slot="thought-rail-dot"].thought-rail-dot--running { + animation: none; + opacity: 1; + } +} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 834e15424..f3cd58609 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -19,6 +19,7 @@ import { useMutation } from "@tanstack/solid-query" import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual" import { Accordion } from "@opencode-ai/ui/accordion" import { AmicodeEntityRail } from "@opencode-ai/ui/amicode-entity-rail" +import { ThoughtRail, THOUGHT_RAIL_INSET, shouldRenderRail } from "./thought-rail" import { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" import { AmicodeEntityView, @@ -1267,6 +1268,14 @@ export function MessageTimeline(props: { const row = input.row() return row._tag === "AssistantPart" && row.previousAssistantPart } + // The thought rail: a spine down a turn's assistant steps. Drawn per-row + // because the timeline is virtualised and consecutive rows share no ancestor. + const rail = () => { + const row = input.row() + if (row._tag !== "AssistantPart") return undefined + if (!shouldRenderRail(row)) return undefined + return { first: !row.previousAssistantPart, last: row.lastAssistantPart, running: row.turnRunning } + } return (
- {input.children} + {(r) => } +
{input.children}
) diff --git a/packages/app/src/pages/session/timeline/projection.test.ts b/packages/app/src/pages/session/timeline/projection.test.ts index 68da2c2fe..76563595d 100644 --- a/packages/app/src/pages/session/timeline/projection.test.ts +++ b/packages/app/src/pages/session/timeline/projection.test.ts @@ -12,6 +12,8 @@ const context = (key: string, partIDs: string[], userMessageID = "user-1") => refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })), } satisfies PartGroup, previousAssistantPart: false, + lastAssistantPart: false, + turnRunning: false, }) const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 879646e86..32ecca711 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -24,6 +24,8 @@ export type TimelineRowMap = { userMessageID: string group: PartGroup previousAssistantPart: boolean + lastAssistantPart: boolean + turnRunning: boolean } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } @@ -168,7 +170,16 @@ export namespace Timeline { } let assistantGroupIndex = 0 - assistantItems.forEach((item) => { + // The thought rail fills a step when its SUCCESSOR appears — the same grammar + // the website animation uses. That deliberately sidesteps out-of-order tool + // completion: adjacency decides, not each tool's own lifecycle, so a filled + // dot can never appear above a hollow one. + const lastRenderableIndex = assistantItems.reduce( + (acc, item, index) => (item.type === "interrupted" ? acc : index), + -1, + ) + const turnIsRunning = isActive && status === "busy" && !error + assistantItems.forEach((item, itemIndex) => { if (item.type === "interrupted") { rows.push( new TimelineRow.TurnDivider({ @@ -184,6 +195,8 @@ export namespace Timeline { userMessageID: userMessage.id, group: item.group, previousAssistantPart: assistantGroupIndex > 0, + lastAssistantPart: itemIndex === lastRenderableIndex, + turnRunning: turnIsRunning, }), ) assistantGroupIndex += 1 diff --git a/packages/app/src/pages/session/timeline/thought-rail.test.ts b/packages/app/src/pages/session/timeline/thought-rail.test.ts new file mode 100644 index 000000000..75c769864 --- /dev/null +++ b/packages/app/src/pages/session/timeline/thought-rail.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { shouldRenderRail } from "./thought-rail" + +// The rail's grammar, stated as tests. A step is "running" only when it is the +// TAIL of a turn that is still working; everything above it has by definition +// been succeeded. That is what guarantees a hollow dot can never sit above a +// filled one, however the underlying tools complete. +const railState = (row: { previousAssistantPart: boolean; lastAssistantPart: boolean; turnRunning: boolean }) => ({ + render: shouldRenderRail(row), + first: !row.previousAssistantPart, + last: row.lastAssistantPart, + running: row.lastAssistantPart && row.turnRunning, +}) + +/** Build the rows a turn of `n` steps produces, mirroring rows.ts. */ +const turn = (n: number, running: boolean) => + Array.from({ length: n }, (_, i) => + railState({ previousAssistantPart: i > 0, lastAssistantPart: i === n - 1, turnRunning: running }), + ) + +describe("thought rail", () => { + test("a single-step turn draws no rail — one dot is decoration, not a sequence", () => { + expect(turn(1, false)[0].render).toBe(false) + expect(turn(1, true)[0].render).toBe(false) + }) + + test("a multi-step turn draws a rail on every step", () => { + expect(turn(4, false).every((s) => s.render)).toBe(true) + }) + + test("exactly one dot is running, and it is the tail", () => { + const steps = turn(5, true) + const running = steps.filter((s) => s.running) + expect(running).toHaveLength(1) + expect(steps[steps.length - 1].running).toBe(true) + }) + + test("no dot is running once the turn finishes — the tail fills too", () => { + expect(turn(5, false).some((s) => s.running)).toBe(false) + }) + + test("a hollow dot never sits above a filled one, at any length", () => { + for (const n of [2, 3, 7, 20]) { + const steps = turn(n, true) + const firstRunning = steps.findIndex((s) => s.running) + // everything after the running step must not exist; it is the tail + expect(firstRunning).toBe(n - 1) + expect(steps.slice(0, firstRunning).some((s) => s.running)).toBe(false) + } + }) + + test("first and last are flagged so the line does not overshoot either end", () => { + const steps = turn(3, false) + expect(steps.map((s) => s.first)).toEqual([true, false, false]) + expect(steps.map((s) => s.last)).toEqual([false, false, true]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/thought-rail.tsx b/packages/app/src/pages/session/timeline/thought-rail.tsx new file mode 100644 index 000000000..5dd086c30 --- /dev/null +++ b/packages/app/src/pages/session/timeline/thought-rail.tsx @@ -0,0 +1,88 @@ +// AMICODE: the thought rail — a vertical spine down a turn's assistant steps, +// ported from the website's /amicode animation (harmoniqs-ai +// app/components/demo/parts.jsx, `Step`). +// +// TWO THINGS THAT LOOK LIKE MISTAKES AND ARE NOT: +// +// 1. The rail is drawn as a PER-ROW SEGMENT, not as a border-left on a shared +// container. It has to be. The timeline is virtualised (@tanstack/solid-virtual): +// every row is an absolutely-positioned box and consecutive rows share no +// ancestor except the total-height spacer, so a container spine is structurally +// impossible here. The site independently arrived at the same per-row approach, +// which is why the port is cheap. Segments meet because each row already owns a +// 12px `pt-3` gap that the segment spans. +// +// 2. "done" is decided by ADJACENCY — a step is finished once a successor exists — +// not by that step's own tool lifecycle. Tools can complete out of order or run +// in parallel, so asking each row "are you finished?" would let a filled dot sit +// above a hollow one and destroy the rail's grammar. Adjacency makes the +// sequence monotonic by construction. It is also exactly what the website does: +// a step flips filled no later than the moment the next one appears. +// +// Hollow therefore means RUNNING (the tail of a turn still in flight), never +// "planned". The website does not preview future steps either — its scenes gate +// every entry on `t >= from`, so an unstarted step is never in the DOM. Showing +// the path ahead needs a real plan source (score stages), which is a later step. + +import { Show } from "solid-js" + +const NODE = 7 // dot diameter, px — matches the site's Step +const DOT_TOP = 7.5 // px from the row's top edge to the dot's top + +export function ThoughtRail(props: { + /** first step of the turn — the line must not run above the dot */ + first: boolean + /** last step of the turn — the line must not run below the dot */ + last: boolean + /** the turn is still working, so this tail step is in flight */ + running: boolean +}) { + // Only the tail of a still-running turn is hollow. Everything above it has, + // by definition, been succeeded. + const isRunning = () => props.last && props.running + return ( + <> +