From 49081a4e249fa3fa87265f008d1cd6688512f875 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 12:57:36 -0400 Subject: [PATCH] fix(tui): flash tabs on prompt admission (#39702) --- packages/tui/src/component/session-tabs.tsx | 1 + packages/tui/src/component/tab-pulse.tsx | 20 ++++ packages/tui/src/context/session-tabs.tsx | 18 ++- .../system/storybook/session-tabs.tsx | 2 +- .../{tab-pulse.test.ts => tab-pulse.test.tsx} | 48 ++++++++ .../tui/test/context/session-tabs.test.tsx | 110 ++++++++++++++++++ 6 files changed, 197 insertions(+), 2 deletions(-) rename packages/tui/test/component/{tab-pulse.test.ts => tab-pulse.test.tsx} (65%) create mode 100644 packages/tui/test/context/session-tabs.test.tsx diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 2dadb40118f..69d35772148 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -283,6 +283,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati & { enabled?: boolean active?: boolean + promptPulse?: number complete?: boolean glow?: boolean breathe?: boolean @@ -28,6 +29,7 @@ const COMPLETION_OPACITY = 0.18 const EDGE_FLASH_DURATION = 800 const EDGE_FLASH_ATTACK = 0.1 const EDGE_FLASH_OPACITY = 0.1 +const PROMPT_FLASH_SCALE = 2 const GLOW_IGNITION_DURATION = 600 const GLOW_IGNITION_PEAK = 1.5 const GLOW_IGNITION_ATTACK = 0.3 @@ -102,6 +104,11 @@ class Envelope { this.scale = scale } + restart(scale = 1) { + this.clock = 0 + this.scale = scale + } + stop() { this.clock = undefined } @@ -127,6 +134,7 @@ const envelopeActive = (envelope: Envelope) => envelope.active class TabPulseRenderable extends Renderable { private _enabled: boolean private _active: boolean + private _promptPulse: number private _complete: boolean private _glow: boolean private _breathe: boolean @@ -154,6 +162,7 @@ class TabPulseRenderable extends Renderable { super(ctx, { ...options, height: 1, live: enabled && active }) this._enabled = enabled this._active = active + this._promptPulse = options.promptPulse ?? 0 this._complete = options.complete ?? false this._glow = options.glow ?? false this._breathe = options.breathe ?? false @@ -222,6 +231,15 @@ class TabPulseRenderable extends Renderable { this.requestRender() } + set promptPulse(value: number) { + if (value === this._promptPulse) return + this._promptPulse = value + if (!this._enabled) return + this.edgeFlash.restart(PROMPT_FLASH_SCALE) + this.live = true + this.requestRender() + } + set complete(value: boolean) { if (value === this._complete) return this._complete = value @@ -368,6 +386,7 @@ extend({ tab_pulse: TabPulseRenderable }) export function TabPulse(props: { enabled?: boolean active: boolean + promptPulse?: number complete?: boolean glow?: boolean breathe?: boolean @@ -385,6 +404,7 @@ export function TabPulse(props: { width="100%" enabled={props.enabled ?? true} active={props.active} + promptPulse={props.promptPulse ?? 0} complete={props.complete ?? false} glow={props.glow ?? false} breathe={props.breathe ?? false} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 6df7a2cbd21..bee249369a1 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useClient } from "./client" @@ -55,6 +55,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp key: "sessionID", }) const fallback = empty() + const [promptPulses, setPromptPulses] = createSignal>({}) let history: SessionTabHistory = { entries: [], index: -1 } function state() { @@ -77,6 +78,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const family = members.length > 0 ? members : [session] return { unread: state().unread[session], + promptPulse: promptPulses()[session] ?? 0, attention: family.some( (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0, ), @@ -176,6 +178,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity"))) onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error"))) + onCleanup( + event.on("session.input.admitted", (evt) => { + if (!enabled() || evt.data.input.type !== "user") return + const sessionID = root(evt.data.sessionID) + if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return + setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 })) + }), + ) onCleanup( event.on("session.error", (evt) => { if (evt.data.sessionID) markUnread(evt.data.sessionID, "error") @@ -201,6 +211,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp draft.tabs = closeSessionTab(draft.tabs, target).tabs delete draft.unread[target] }) + setPromptPulses((pulses) => { + if (pulses[target] === undefined) return pulses + const next = { ...pulses } + delete next[target] + return next + }) if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) } diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx index 81fd627da07..423a7e4005c 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -23,7 +23,7 @@ const FIXTURE_TABS = [ { sessionID: "fixture-12", title: "Prepare review" }, ] -const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false } +const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false } const RUN_DURATION = 1_800 const RESUME_DURATION = 900 diff --git a/packages/tui/test/component/tab-pulse.test.ts b/packages/tui/test/component/tab-pulse.test.tsx similarity index 65% rename from packages/tui/test/component/tab-pulse.test.ts rename to packages/tui/test/component/tab-pulse.test.tsx index 0e157f6ba33..6fceb27b2bb 100644 --- a/packages/tui/test/component/tab-pulse.test.ts +++ b/packages/tui/test/component/tab-pulse.test.tsx @@ -1,6 +1,10 @@ +/** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal } from "solid-js" import { + TabPulse, blendTabPulseColor, completionPulseOpacity, glowIgnitionLevel, @@ -8,6 +12,50 @@ import { } from "../../src/component/tab-pulse" import { tint } from "../../src/theme/color" +test("a prompt pulse restarts the neutral edge flash while the tab remains busy", async () => { + const background = RGBA.fromHex("#101010") + const flash = RGBA.fromHex("#f0f0f0") + const [promptPulse, setPromptPulse] = createSignal(0) + const app = await testRender( + () => ( + + + + ), + { width: 8, height: 1 }, + ) + + const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg + + try { + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeTrue() + + setPromptPulse(1) + await Bun.sleep(80) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeFalse() + expect(firstBackground()?.r ?? 0).toBeGreaterThan(0.17) + + await Bun.sleep(800) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeTrue() + + setPromptPulse(2) + await Bun.sleep(80) + await app.renderOnce() + expect(firstBackground()?.equals(background)).toBeFalse() + } finally { + app.renderer.destroy() + } +}) + test("completion pulse rises quickly and fades over the remaining duration", () => { expect(completionPulseOpacity(0)).toBe(0) expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx new file mode 100644 index 00000000000..cb779166880 --- /dev/null +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -0,0 +1,110 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import type { OpenCodeEvent } from "@opencode-ai/client" +import { testRender } from "@opentui/solid" +import { mkdtempSync, rmSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { ConfigProvider } from "../../src/config" +import { ClientProvider, useClient } from "../../src/context/client" +import { DataProvider } from "../../src/context/data" +import { RouteProvider, useRoute } from "../../src/context/route" +import { TuiAppProvider } from "../../src/context/runtime" +import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" +import { StorageProvider } from "../../src/context/storage" +import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +async function wait(fn: () => boolean, timeout = 2_000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +test("user prompt admissions pulse an already-busy background tab", async () => { + const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) + const events = createEventStream() + const calls = createFetch(undefined, events) + let tabs!: ReturnType + let route!: ReturnType + let client!: ReturnType + + function Probe() { + tabs = useSessionTabs() + route = useRoute() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + + + + + )) + + const emit = (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }) + const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({ + id: `evt_${inputID}`, + created: Date.now(), + type: "session.input.admitted", + durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 }, + data: { + sessionID, + inputID, + input: { type: "user", data: { text: inputID }, delivery: "steer" }, + }, + }) + + try { + await wait( + () => client.connection.status() === "connected" && tabs.tabs().some((tab) => tab.sessionID === "background"), + ) + route.navigate({ type: "session", sessionID: "active" }) + await wait(() => tabs.current() === "active" && tabs.tabs().length === 2) + + emit({ + id: "evt_context", + created: Date.now(), + type: "session.input.admitted", + durable: { aggregateID: "background", seq: 0, version: 1 }, + data: { + sessionID: "background", + inputID: "msg_context", + input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" }, + }, + }) + await Bun.sleep(20) + expect(tabs.status("background").promptPulse).toBe(0) + + emit(admitted("background", "msg_1")) + await wait(() => tabs.status("background").promptPulse === 1 && tabs.status("background").busy) + + emit(admitted("background", "msg_2")) + await wait(() => tabs.status("background").promptPulse === 2) + + emit(admitted("active", "msg_3")) + await Bun.sleep(20) + expect(tabs.status("active").promptPulse).toBe(0) + expect(tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true }) + } finally { + app.renderer.destroy() + rmSync(state, { recursive: true, force: true }) + } +})