From bbb1b5e7d0a073e84b1c3de21ef6f396ad287d09 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 10 Aug 2026 13:47:31 -0400 Subject: [PATCH] fix(tui): delay background work hint (#41577) --- packages/tui/src/routes/session/index.tsx | 20 ++++--- packages/tui/src/util/delayed-presence.ts | 16 ++++++ .../tui/test/util/delayed-presence.test.ts | 55 +++++++++++++++++++ 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/tui/src/util/delayed-presence.ts create mode 100644 packages/tui/test/util/delayed-presence.test.ts diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7299fbea1a..eb382baba9 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -102,11 +102,13 @@ import { useSessionTabs } from "../../context/session-tabs" import { createSingleFlight } from "../../util/single-flight" import type { SessionPending } from "@opencode-ai/schema/session-pending" import { generateThinkingSyntax } from "./thinking-syntax" +import { createDelayedPresence } from "../../util/delayed-presence" addDefaultParsers(parsers.parsers) // Exclude temporary bottom space when measuring the real transcript height. const NAVIGATION_SLACK_ID = "session-navigation-slack" +const BACKGROUND_TOOL_HINT_DELAY = 1_000 // Tail-first transcript mounting: rows mounted with the session, then backfill cadence. // The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript @@ -1326,18 +1328,20 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) { function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const theme = useTheme() const shortcut = Keymap.useShortcut("session.background") - const visible = createMemo(() => { + const running = createMemo(() => { + if (!shortcut()) return const current = props.messages.findLast( (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, ) - return ( - current?.content.some((part) => { - if (part.type !== "tool" || part.state.status !== "running") return false - const display = toolDisplay(part.name) - return display === "shell" || display === "subagent" - }) ?? false - ) + const part = current?.content.find((part): part is SessionMessageAssistantTool => { + if (part.type !== "tool" || part.state.status !== "running") return false + const name = canonicalToolName(part.name) + return name === "shell" || name === "subagent" + }) + if (!current || !part) return + return `${current.id}:${part.id}` }) + const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY) return ( {(value) => ( diff --git a/packages/tui/src/util/delayed-presence.ts b/packages/tui/src/util/delayed-presence.ts new file mode 100644 index 0000000000..75c0b1a631 --- /dev/null +++ b/packages/tui/src/util/delayed-presence.ts @@ -0,0 +1,16 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +export function createDelayedPresence(source: Accessor, delay: number) { + const [visible, setVisible] = createSignal(false) + + createEffect(() => { + const value = source() + setVisible(false) + if (value === undefined) return + + const timer = setTimeout(() => setVisible(true), delay) + onCleanup(() => clearTimeout(timer)) + }) + + return visible +} diff --git a/packages/tui/test/util/delayed-presence.test.ts b/packages/tui/test/util/delayed-presence.test.ts new file mode 100644 index 0000000000..599bd02ff7 --- /dev/null +++ b/packages/tui/test/util/delayed-presence.test.ts @@ -0,0 +1,55 @@ +import { expect, jest, test } from "bun:test" +import { createRoot, createSignal } from "solid-js" +import { createDelayedPresence } from "../../src/util/delayed-presence" + +test("shows only after the same value remains present for the delay", async () => { + jest.useFakeTimers() + const scope = createRoot((dispose) => { + const [value, setValue] = createSignal() + return { dispose, setValue, visible: createDelayedPresence(value, 1_000) } + }) + + try { + scope.setValue("first") + await Promise.resolve() + jest.advanceTimersByTime(500) + expect(scope.visible()).toBe(false) + + scope.setValue("second") + await Promise.resolve() + jest.advanceTimersByTime(999) + expect(scope.visible()).toBe(false) + jest.advanceTimersByTime(1) + expect(scope.visible()).toBe(true) + } finally { + scope.dispose() + jest.useRealTimers() + } +}) + +test("cancels the delay when the value disappears or the owner is disposed", async () => { + jest.useFakeTimers() + const scope = createRoot((dispose) => { + const [value, setValue] = createSignal() + return { dispose, setValue, visible: createDelayedPresence(value, 1_000) } + }) + + try { + scope.setValue("running") + await Promise.resolve() + jest.advanceTimersByTime(500) + scope.setValue(undefined) + await Promise.resolve() + jest.advanceTimersByTime(1_000) + expect(scope.visible()).toBe(false) + + scope.setValue("running") + await Promise.resolve() + scope.dispose() + jest.advanceTimersByTime(1_000) + expect(scope.visible()).toBe(false) + } finally { + scope.dispose() + jest.useRealTimers() + } +})