Просмотр исходного кода

fix(tui): flash tabs on prompt admission (#39702)

Kit Langton 2 недель назад
Родитель
Сommit
49081a4e24

+ 1 - 0
packages/tui/src/component/session-tabs.tsx

@@ -283,6 +283,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
               <TabPulse
                 enabled={animations()}
                 active={status().busy && !status().attention}
+                promptPulse={status().promptPulse}
                 complete={status().complete && !status().attention}
                 glow={glows()}
                 breathe={status().attention}

+ 20 - 0
packages/tui/src/component/tab-pulse.tsx

@@ -4,6 +4,7 @@ import { extend } from "@opentui/solid"
 type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
   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}

+ 17 - 1
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<Record<string, number>>({})
     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" })
     }
 

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

+ 48 - 0
packages/tui/test/component/tab-pulse.test.ts → 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(
+    () => (
+      <box width={8} height={1} backgroundColor={background}>
+        <TabPulse
+          active={true}
+          promptPulse={promptPulse()}
+          color={background}
+          flashColor={flash}
+          backgroundColor={background}
+        />
+      </box>
+    ),
+    { 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)

+ 110 - 0
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<typeof useSessionTabs>
+  let route!: ReturnType<typeof useRoute>
+  let client!: ReturnType<typeof useClient>
+
+  function Probe() {
+    tabs = useSessionTabs()
+    route = useRoute()
+    client = useClient()
+    return <box />
+  }
+
+  const app = await testRender(() => (
+    <TestTuiContexts paths={{ state }}>
+      <TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
+        <StorageProvider>
+          <ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
+            <RouteProvider initialRoute={{ type: "session", sessionID: "background" }}>
+              <ClientProvider api={createApi(calls.fetch)}>
+                <DataProvider>
+                  <SessionTabsProvider>
+                    <Probe />
+                  </SessionTabsProvider>
+                </DataProvider>
+              </ClientProvider>
+            </RouteProvider>
+          </ConfigProvider>
+        </StorageProvider>
+      </TuiAppProvider>
+    </TestTuiContexts>
+  ))
+
+  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 })
+  }
+})