diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 8a56d05ad..4388f4185 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -16,7 +16,12 @@ import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +// HARNESS (feature/chat-railing-timeline): step markers must flow into the +// store so rows.ts can slice into StepFrames. `patch` remains skipped (file +// diff noise); step-start/finish are filtered only at the render layer +// (renderable() / buildStepSlices), not at the store edge — otherwise +// hasStepMarkers is permanently false and the rail never appears. +const SKIP_PARTS = new Set(["patch"]) const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"]) const SESSION_CONTENT_EVENTS = new Set([ "session.diff", diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index e9595e137..4c12585e5 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1359,7 +1359,9 @@ describe("server session", () => { test("does not cache skipped optimistic parts", () => { const message = userMessage("message") - const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const } + // HARNESS: only `patch` remains skipped; step-start now flows through so the + // rail can slice. Use patch as the canonical skipped type for this check. + const part = { id: "part", sessionID: "child", messageID: message.id, type: "patch" as const } const store = setup({ child: session("child") }).store store.optimistic.add({ sessionID: "child", message, parts: [part] }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 40093c6ff..c0f1ca159 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -27,7 +27,12 @@ type MessageApi = ServerApi["message"] const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) -const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +// HARNESS (feature/chat-railing-timeline): step markers must flow into the +// store so rows.ts can slice into StepFrames. `patch` remains skipped (file +// diff noise); step-start/finish are filtered only at the render layer +// (renderable() / buildStepSlices), not at the store edge — otherwise +// hasStepMarkers is permanently false and the rail never appears. +const SKIP_PARTS = new Set(["patch"]) const initialMessagePageSize = 20 const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 8be156388..1f570469a 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -31,10 +31,12 @@ import { Button } from "@opencode-ai/ui/button" import { Card } from "@opencode-ai/ui/card" import { ContextToolGroup, + EditToolGroup, Message, MessageDivider, Part as MessagePart, partDefaultOpen, + ShellToolGroup, type UserActions, } from "@opencode-ai/session-ui/message-part" import { DiffChanges } from "@opencode-ai/ui/diff-changes" @@ -1396,6 +1398,115 @@ export function MessageTimeline(props: { ) } + case "StepFrame": { + const stepFrameRow = row as Accessor> + const frame = () => stepFrameRow() + const isRunning = () => frame().state === "running" + const isError = () => frame().state === "error" + const dotActive = () => isRunning() && frame().lastStep + return ( + }> +
+
+ +
+ Step {frame().stepIndex + 1} + + {frame().reasoningHeading ? frame().reasoningHeading : frame().groups.length === 1 && frame().groups[0]?.type === "part" ? "response" : `${frame().groups.length} groups`} + + +
+ +
{frame().reasoningHeading}
+
+
+ + {(group) => { + if (group.type === "context") { + const parts = () => + group.refs + .map((ref) => getMsgPart(ref.messageID, ref.partID)) + .filter((p): p is ToolPart => p?.type === "tool") + const key = () => `context:${group.key}` + const open = () => toolOpen[key()] === true + return ( + setToolOpen(key(), v)} + busy={workingTurn(frame().userMessageID) && frame().state === "running"} + onSizeChange={onSizeChange} + /> + ) + } + if (group.type === "shell") { + const parts = () => + group.refs + .map((ref) => getMsgPart(ref.messageID, ref.partID)) + .filter((p): p is ToolPart => p?.type === "tool") + return + } + if (group.type === "edit") { + const parts = () => + group.refs + .map((ref) => getMsgPart(ref.messageID, ref.partID)) + .filter((p): p is ToolPart => p?.type === "tool") + return + } + const msg = () => messageByID().get(group.ref.messageID) + const part = () => getMsgPart(group.ref.messageID, group.ref.partID) + const defaultOpen = () => { + const item = part() + if (!item) return + return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) + } + return ( + + {(message) => ( + + {(part) => ( + setToolOpen(part().id, open)} + deferToolContent + virtualizeDiff={false} + onContentRendered={onSizeChange} + /> + )} + + )} + + ) + }} + +
+
+
+
+ ) + } case "Thinking": { const thinkingRow = row as Accessor> return ( @@ -2067,6 +2178,7 @@ export function MessageTimeline(props: { )} + {/* amicode: problem-header rail (renders only when the session has amicode_* parts) + ask/ui bridges — ported from the pre-merge message-timeline (AMICODE-PATCHES.md "Upstream sync 2026-08-01") */} @@ -2171,6 +2283,7 @@ export function MessageTimeline(props: { + {/* amicode#271: no-header fallback (new untitled sessions only) */} {(_) => { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index a5952a5e6..f6d0d23a0 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -1,5 +1,6 @@ import { describe, expect, mock, test } from "bun:test" import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import type { UserMessage } from "@opencode-ai/sdk/v2" import { normalizeSessionMessages } from "@/utils/session-message" mock.module("@opencode-ai/session-ui/message-part", () => ({ @@ -168,6 +169,201 @@ describe("current session timeline rows", () => { ]) }) + test("step rail: all steps in a busy turn show running state", () => { + // When session is busy (agent working after user's message), ALL steps + // show "running" — the entire rail is yellow. Once session goes idle, all flip to white. + const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } + const assistantMsg = { + id: "msg_a", + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "working on it" }], + time: { created: 2 }, + } + const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((m) => [m.id, m])) + + // Inject step-start before the text part (simulating the event reducer) + const baseParts = normalized.parts.get("msg_a") ?? [] + const partsWithStep = [ + { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + ...baseParts, + ] + + const result = Timeline.constructMessageRows( + messages.get("msg_u")! as UserMessage, + (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), + [messages.get("msg_a")!] as any, + 0, + true, + "busy", + true, + true, + ) + + const stepFrames = result.filter((row) => row._tag === "StepFrame") + expect(stepFrames.length).toBeGreaterThan(0) + expect(stepFrames[0]!.state).toBe("running") + }) + + test("step rail: busy turn with tool step also shows running state", () => { + const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } + const assistantMsg = { + id: "msg_a", + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "tool", id: "tool_1", name: "bash", time: { created: 2 }, state: { status: "running", input: { command: "ls" }, metadata: {} } }], + time: { created: 2 }, + } + const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((m) => [m.id, m])) + + const baseParts = normalized.parts.get("msg_a") ?? [] + const partsWithStep = [ + { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + ...baseParts, + ] + + const result = Timeline.constructMessageRows( + messages.get("msg_u")! as UserMessage, + (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), + [messages.get("msg_a")!] as any, + 0, + true, + "busy", + true, + true, + ) + + const stepFrames = result.filter((row) => row._tag === "StepFrame") + expect(stepFrames.length).toBeGreaterThan(0) + expect(stepFrames[0]!.state).toBe("running") + }) + + test("step rail: completed step shows done state", () => { + const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } + const assistantMsg = { + id: "msg_a", + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "done" }], + time: { created: 2, completed: 3 }, + } + const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((m) => [m.id, m])) + + const baseParts = normalized.parts.get("msg_a") ?? [] + const partsWithStep = [ + { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + ...baseParts, + { id: "step_0_end", sessionID: "ses_1", messageID: "msg_a", type: "step-finish" as const, reason: "end_turn", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + ] + + // Session is idle — the step should be "done" + const result = Timeline.constructMessageRows( + messages.get("msg_u")! as UserMessage, + (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), + [messages.get("msg_a")!] as any, + 0, + true, + "idle", + true, + true, + ) + + const stepFrames = result.filter((row) => row._tag === "StepFrame") + expect(stepFrames.length).toBeGreaterThan(0) + expect(stepFrames[0]!.state).toBe("done") + }) + + test("step rail: step with errored tool shows error state", () => { + const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } + const assistantMsg = { + id: "msg_a", + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "tool", id: "tool_1", name: "bash", time: { created: 2, completed: 3 }, state: { status: "error", input: { command: "fail" }, error: { message: "failed" }, metadata: {} } }], + time: { created: 2 }, + } + const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((m) => [m.id, m])) + + const baseParts = normalized.parts.get("msg_a") ?? [] + const partsWithStep = [ + { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + ...baseParts, + ] + + const result = Timeline.constructMessageRows( + messages.get("msg_u")! as UserMessage, + (messageID) => (messageID === "msg_a" ? partsWithStep : normalized.parts.get(messageID) ?? []), + [messages.get("msg_a")!] as any, + 0, + true, + "busy", + true, + true, + ) + + const stepFrames = result.filter((row) => row._tag === "StepFrame") + expect(stepFrames.length).toBeGreaterThan(0) + expect(stepFrames[0]!.state).toBe("error") + }) + + test("step rail: all steps in a busy turn show running state (yellow rail)", () => { + // Two steps: step 0 (text, finished) and step 1 (text, still open). + // Both should be "running" because the session is busy — the entire turn's + // rail is yellow until the agent finishes and the session goes idle. + const userMsg = { id: "msg_u", type: "user" as const, text: "hello", time: { created: 1 } } + const assistantMsg = { + id: "msg_a", + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "first" }, { type: "text", text: "second" }], + time: { created: 2 }, + } + const source = [userMsg, assistantMsg] as unknown as SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((m) => [m.id, m])) + + const baseParts = normalized.parts.get("msg_a") ?? [] + // Two step slices: step 0 has part[0], step 1 has part[1] + const partsWithSteps = [ + { id: "step_0", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + baseParts[0]!, + { id: "step_0_end", sessionID: "ses_1", messageID: "msg_a", type: "step-finish" as const, reason: "end_turn", cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, + { id: "step_1", sessionID: "ses_1", messageID: "msg_a", type: "step-start" as const }, + baseParts[1]!, + ] + + const result = Timeline.constructMessageRows( + messages.get("msg_u")! as UserMessage, + (messageID) => (messageID === "msg_a" ? partsWithSteps : normalized.parts.get(messageID) ?? []), + [messages.get("msg_a")!] as any, + 0, + true, + "busy", + true, + true, + ) + + const stepFrames = result.filter((row) => row._tag === "StepFrame") + expect(stepFrames.length).toBe(2) + expect(stepFrames[0]!.state).toBe("running") + expect(stepFrames[0]!.lastStep).toBe(false) + expect(stepFrames[1]!.state).toBe("running") + expect(stepFrames[1]!.lastStep).toBe(true) + }) + test("removes a failed assistant error when the turn continues streaming", () => { const source = [ { id: "msg_user", type: "user", text: "recover", time: { created: 1 } }, diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 879646e86..cdd04fba1 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -25,6 +25,14 @@ export type TimelineRowMap = { group: PartGroup previousAssistantPart: boolean } + StepFrame: { + userMessageID: string + stepIndex: number + stepKey: string + state: "pending" | "running" | "done" | "error" + groups: PartGroup[] + reasoningHeading?: string + } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } DiffSummary: { userMessageID: string; diffs: SummaryDiff[] } @@ -167,27 +175,68 @@ export namespace Timeline { ) } - let assistantGroupIndex = 0 - assistantItems.forEach((item) => { - if (item.type === "interrupted") { + // HARNESS — StepFrame slicing: if SDK step-start markers exist, chunk by them; + // else fallback to per-assistant-message chunking. When markers exist we + // emit StepFrames (new rail UI); otherwise we keep the legacy AssistantPart + // rows so existing tests/snapshots stay green. + const rawPartsByMessage = assistantMessages.map((m) => getMessageParts(m.id)) + const hasStepMarkers = rawPartsByMessage.some((parts) => parts.some((p) => p.type === "step-start" || p.type === "step-finish")) + if (hasStepMarkers) { + const stepSlices = buildStepSlices(assistantMessages, getMessageParts, showReasoning) + stepSlices.forEach((slice, stepIdx) => { + const groups = groupParts(slice.refs.map((r) => ({ messageID: r.messageID, part: r.part }))) + if (groups.length === 0) return + const isLast = stepIdx === stepSlices.length - 1 + const hasError = slice.refs.some((r) => r.part.type === "tool" && r.part.state.status === "error") + const state: "pending" | "running" | "done" | "error" = hasError ? "error" : isActive && status === "busy" ? "running" : "done" + const heading = slice.refs + .map((r) => r.part) + .map((p) => (p?.type === "reasoning" && (p as any).text ? reasoningHeading((p as any).text) : undefined)) + .find((v): v is string => !!v) + rows.push( + new TimelineRow.StepFrame({ + userMessageID: userMessage.id, + stepIndex: stepIdx, + stepKey: `step:${userMessage.id}:${stepIdx}:${slice.key}`, + state, + lastStep: isLast, + groups, + reasoningHeading: heading, + }), + ) + }) + // still surface the interrupted divider after stepping + if (interrupted && !compaction) { rows.push( new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted", }), ) - return } + } else { + let assistantGroupIndex = 0 + assistantItems.forEach((item) => { + if (item.type === "interrupted") { + rows.push( + new TimelineRow.TurnDivider({ + userMessageID: userMessage.id, + label: "interrupted", + }), + ) + return + } - rows.push( - new TimelineRow.AssistantPart({ - userMessageID: userMessage.id, - group: item.group, - previousAssistantPart: assistantGroupIndex > 0, - }), - ) - assistantGroupIndex += 1 - }) + rows.push( + new TimelineRow.AssistantPart({ + userMessageID: userMessage.id, + group: item.group, + previousAssistantPart: assistantGroupIndex > 0, + }), + ) + assistantGroupIndex += 1 + }) + } if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { const heading = assistantMessages @@ -315,6 +364,46 @@ export namespace Timeline { function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } + + // HARNESS — buildStepSlices: chunk renderable refs by step-start markers. + // Each slice's `key` is the first part id in the slice (stable for + // TimelineRow.key); refs are already filtered via renderable so the + // subsequent groupParts sees only visible parts. + function buildStepSlices( + assistantMessages: AssistantMessage[], + getMessageParts: (messageID: string) => Part[], + showReasoning: boolean, + ): Array<{ key: string; refs: Array<{ messageID: string; partID: string; part: Part }> }> { + const slices: Array<{ key: string; refs: Array<{ messageID: string; partID: string; part: Part }> }> = [] + let current: Array<{ messageID: string; partID: string; part: Part }> = [] + let sliceKey = "" + const flush = () => { + if (current.length === 0) return + slices.push({ key: sliceKey || current[0]!.partID, refs: current }) + current = [] + sliceKey = "" + } + for (const msg of assistantMessages) { + const parts = getMessageParts(msg.id) + for (const part of parts) { + if (part.type === "step-start") { + flush() + sliceKey = part.id + continue + } + if (part.type === "step-finish") { + flush() + sliceKey = "" + continue + } + if (!renderable(part, showReasoning)) continue + if (!sliceKey && current.length === 0) sliceKey = part.id + current.push({ messageID: msg.id, partID: part.id, part }) + } + } + flush() + return slices + } } export namespace MessageComment { diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b2..c5da25b5c 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -24,6 +24,20 @@ export namespace TimelineRow { group: PartGroup previousAssistantPart: boolean }> {} + // HARNESS — StepFrame: one model-request + its tools within a turn, surfaced + // from SDK `step-start`/`step-finish` parts when present, else fallback to + // one frame per assistant-message grouping. The rail renders per-turn, frames + // render per-step; when no step markers exist we emit a single StepFrame + // that wraps the turn's legacy AssistantPart rows (backward-compat). + export class StepFrame extends Data.TaggedClass("StepFrame")<{ + userMessageID: string + stepIndex: number + stepKey: string + state: "pending" | "running" | "done" | "error" + lastStep: boolean + groups: PartGroup[] + reasoningHeading?: string + }> {} export class Thinking extends Data.TaggedClass("Thinking")<{ userMessageID: string reasoningHeading?: string @@ -46,6 +60,7 @@ export namespace TimelineRow { | UserMessage | TurnDivider | AssistantPart + | StepFrame | Thinking | DiffSummary | Error @@ -63,6 +78,8 @@ export namespace TimelineRow { return `turn-divider:${row.userMessageID}:${row.label}` case "AssistantPart": return `assistant-part:${row.userMessageID}:${row.group.key}` + case "StepFrame": + return `step-frame:${row.userMessageID}:${row.stepKey}` case "Thinking": return `thinking:${row.userMessageID}` case "DiffSummary": diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 9d0eccb19..225fa7a9b 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -785,7 +785,7 @@ cursor: default; } /* …except a not-recorded chip that IS a button (the pulse chip, which opens the - Pulse Inspector before a pulse is banked): keep the dotted not-yet look, but it + Run Inspector before a pulse is banked): keep the dotted not-yet look, but it must READ clickable at rest — hover feedback alone failed the glance test (Kate 2026-07-28: "doesn't appear clickable"). Three signals: full-strength ink at weight 600 (faint ink is how a chip says "inert", and this one isn't; @@ -1474,6 +1474,8 @@ background: color-mix(in srgb, var(--amber-dark-9) 18%, transparent); } + + /* ---- DEV channel tag (titlebar) ----------------------------------------- */ /* Green pill signals developer mode is active — visually distinct from the amber BETA so you never confuse a dev build with a release. Uses the @@ -1602,3 +1604,21 @@ opacity: 0.45; cursor: not-allowed; } + +/* ---- HARNESS — Step rail (feature/chat-railing-timeline, Claude-like) ----- */ +/* No boxes — a thin left rail + dot per step, like Claude Code's timeline. + The rail lives on the StepFrame itself (border-l), the dot is the status + signal. Running → accent rail + pulsing dot, done → muted rail, error → red. */ +[data-slot="harness-step-frame"] { + transition: border-color 0.18s ease; +} +[data-slot="harness-step-dot"] { + transition: background 0.18s ease, border-color 0.18s ease; +} +[data-slot="harness-step-dot"][data-active="true"] { + animation: harness-pulse-dot 1.4s ease-in-out infinite; +} +@keyframes harness-pulse-dot { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.85); } +}