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/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 (
+ <>
+
+
+ >
+ )
+}
+
+/** The gutter a rail occupies, so content clears it. */
+export const THOUGHT_RAIL_INSET = "pl-4"
+
+/**
+ * A lone step is not a sequence: one dot on its own reads as decoration rather
+ * than as a rail, so a single-part turn gets nothing.
+ */
+export function shouldRenderRail(input: { previousAssistantPart: boolean; lastAssistantPart: boolean }) {
+ const isOnlyStep = !input.previousAssistantPart && input.lastAssistantPart
+ return !isOnlyStep
+}
diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts
index 3905254b2..c2daa8b60 100644
--- a/packages/app/src/pages/session/timeline/timeline-row.ts
+++ b/packages/app/src/pages/session/timeline/timeline-row.ts
@@ -23,6 +23,10 @@ export namespace TimelineRow {
userMessageID: string
group: PartGroup
previousAssistantPart: boolean
+ /** no further assistant part follows in this turn — the rail's tail */
+ lastAssistantPart: boolean
+ /** the turn is still working, so the tail step is in flight rather than done */
+ turnRunning: boolean
}> {}
export class Thinking extends Data.TaggedClass("Thinking")<{
userMessageID: string
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)",