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 ( + <> +