Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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 { Button } from "@opencode-ai/ui/button"
import { BusyWave } from "@opencode-ai/ui/busy-wave"
import { Card } from "@opencode-ai/ui/card"
import {
ContextToolGroup,
Expand All @@ -44,7 +45,6 @@ import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@openc
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type {
AssistantMessage,
Message as MessageType,
Expand All @@ -67,6 +67,7 @@ import { useServerSDK } from "@/context/server-sdk"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { messageAgentColor } from "@/utils/agent"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
Expand Down Expand Up @@ -129,12 +130,12 @@ const markBoundaryGesture = (input: {
}
}

function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) {
function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean; color?: string }) {
const language = useLanguage()

return (
<div data-slot="session-turn-thinking">
<TextShimmer text={language.t("ui.sessionTurn.status.thinking")} />
<BusyWave color={props.color} label={language.t("ui.sessionTurn.status.thinking")} />
<Show when={!props.showReasoningSummaries}>
<TextReveal text={props.reasoningHeading} class="session-turn-thinking-heading" travel={25} duration={700} />
</Show>
Expand Down Expand Up @@ -281,6 +282,7 @@ export function MessageTimeline(props: {
return sync().data.session_status[id] ?? idle
})
const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : []))
const thinkingColor = createMemo(() => messageAgentColor(sessionMessages(), sync().data.agent))
const projectedMessages = createMemo(() => {
const id = sessionID()
if (!id) return []
Expand Down Expand Up @@ -1234,6 +1236,7 @@ export function MessageTimeline(props: {
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
color={thinkingColor()}
/>
</div>
</TimelineRowFrame>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("current session timeline rows", () => {
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:msg_4:reasoning:0",
"thinking:msg_3",
])
})

Expand Down Expand Up @@ -202,6 +203,6 @@ describe("current session timeline rows", () => {
normalized.messages.filter((message) => message.role === "user"),
)

expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart", "Thinking"])
})
})
2 changes: 1 addition & 1 deletion packages/app/src/pages/session/timeline/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export namespace Timeline {
assistantGroupIndex += 1
})

if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) {
if (isActive && status === "busy" && !error) {
const heading = assistantMessages
.flatMap((message) => getMessageParts(message.id))
.map((part) => (part.type === "reasoning" && part.text ? reasoningHeading(part.text) : undefined))
Expand Down
35 changes: 6 additions & 29 deletions packages/session-ui/src/components/session-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { AssistantParts, Message, MessageDivider, PART_MAPPING, type UserActions } from "./message-part"
import { AssistantParts, Message, MessageDivider, type UserActions } from "./message-part"
import { Card } from "@opencode-ai/ui/card"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Icon } from "@opencode-ai/ui/icon"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { BusyWave } from "@opencode-ai/ui/busy-wave"
import { SessionRetry } from "./session-retry"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
Expand Down Expand Up @@ -99,21 +99,6 @@ function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {

const hidden = new Set(["todowrite"])

function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
return "visible" as const
}
if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined
if (part.type === "reasoning") {
if (showReasoningSummaries && part.text?.trim()) return "visible" as const
return
}
if (PART_MAPPING[part.type]) return "visible" as const
return
}

function clean(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
Expand Down Expand Up @@ -351,28 +336,20 @@ export function SessionTurn(
return end - start
})
const assistantDerived = createMemo(() => {
let visible = 0
let reason: string | undefined
const show = showReasoningSummaries()
for (const message of assistantMessages()) {
for (const part of list(data.store.part?.[message.id], emptyParts)) {
if (partState(part, show) === "visible") {
visible++
}
if (part.type === "reasoning" && part.text) {
const h = heading(part.text)
if (h) reason = h
if (h) return h
}
}
}
return { visible, reason }
return undefined
})
const assistantVisible = createMemo(() => assistantDerived().visible)
const reasoningHeading = createMemo(() => assistantDerived().reason)
const reasoningHeading = createMemo(() => assistantDerived())
const showThinking = createMemo(() => {
if (!working() || !!error()) return false
if (status().type === "retry") return false
if (showReasoningSummaries()) return assistantVisible() === 0
return true
})

Expand Down Expand Up @@ -421,7 +398,7 @@ export function SessionTurn(
</Show>
<Show when={showThinking()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<BusyWave label={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries()}>
<TextReveal
text={reasoningHeading()}
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/components/busy-wave.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[data-component="busy-wave"] {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
}
62 changes: 62 additions & 0 deletions packages/ui/src/components/busy-wave.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { BusyWave } from "./busy-wave"
import { create } from "../storybook/scaffold"

const docs = `### Overview
TUI-accurate busy indicator: 8 SVG rectangles sweeping bidirectionally with an alpha trail via CSS color-mix().

Use while a session is working.

### API
- Optional: \`color\` (any CSS color, default \`var(--text-weak)\`), \`label\` (aria-label), \`class\`, \`style\`.

### Variants and states
- Colors inherit the active agent color in production.
- Animation pauses under \`prefers-reduced-motion\`.

### Behavior
- 54-frame bidirectional cycle at 40ms per frame: 8 forward, 9-frame hold, 7 backward, 30-frame hold.
- 6-step exponential alpha trail, inactive segments shrink vertically with a fade factor.

### Accessibility
- \`role="status"\` with \`aria-busy="true"\` and \`aria-label\`.
`

const defaults = {
label: "Thinking",
} as const

const story = create({ title: "UI/BusyWave", mod: { BusyWave }, args: defaults })

export default {
title: "UI/BusyWave",
id: "components-busy-wave",
component: story.meta.component,
tags: ["autodocs"],
args: defaults,
argTypes: {
color: { control: "color" },
label: { control: "text" },
class: { control: "text" },
},
parameters: {
docs: {
description: {
component: docs,
},
},
},
}

export const Basic = story.Basic

export const AgentColor = {
args: {
color: "#2090f5",
},
}

export const StaticLabel = {
args: {
label: "Working",
},
}
42 changes: 42 additions & 0 deletions packages/ui/src/components/busy-wave.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test"
import { SEGMENTS, TOTAL_FRAMES, segmentState } from "./busy-wave"

describe("segmentState", () => {
test("sweeps an active head left to right", () => {
for (let frame = 0; frame < SEGMENTS; frame++) {
const head = segmentState(frame, frame)
expect(head.alpha).toBe(1)
expect(head.active).toBe(true)
}
})

test("renders a gradient trail behind the head", () => {
const head = segmentState(4, 4)
const trailing = segmentState(4, 2)
expect(trailing.alpha).toBeLessThan(head.alpha)
expect(trailing.active).toBe(true)
})

test("marks segments beyond the trail as inactive", () => {
const inactive = segmentState(4, 7)
expect(inactive.active).toBe(false)
})

test("stays within valid alpha bounds across the whole cycle", () => {
for (let frame = 0; frame < TOTAL_FRAMES; frame++) {
for (let char = 0; char < SEGMENTS; char++) {
const state = segmentState(frame, char)
expect(state.alpha).toBeGreaterThanOrEqual(0)
expect(state.alpha).toBeLessThanOrEqual(1)
}
}
})

test("has exactly TRAIL_STEPS active positions when head is mid-sweep", () => {
let active = 0
for (let char = 0; char < SEGMENTS; char++) {
if (segmentState(5, char).active) active++
}
expect(active).toBe(6)
})
})
Loading
Loading