From 8d1a9799f4ef402563cbfa78e69fa1c523f05dd8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:46:28 -0400 Subject: [PATCH] fix(tui): remove completion notice links (#47426) Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com> --- packages/tui/src/routes/session/index.tsx | 45 +----- packages/tui/src/routes/session/rows.ts | 25 ---- .../tui/test/cli/tui/session-rows.test.ts | 71 +-------- packages/tui/test/completion-notices.test.tsx | 137 ++++++++++++++++++ 4 files changed, 139 insertions(+), 139 deletions(-) create mode 100644 packages/tui/test/completion-notices.test.tsx diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1e117a532fa..04d3be6b246 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -87,7 +87,6 @@ import { useLocation } from "../../context/location" import { Slot } from "../../plugin/render" import { usePlugin } from "../../plugin/context" import { - backgroundToolRowIndex, cacheReuseDrop, createSessionRows, messageBoundaryIDs, @@ -95,7 +94,6 @@ import { sessionRowID, turnDuration, turnTokensPerSecond, - type BackgroundToolTarget, type CacheUsage, type PartRef, type SessionRow, @@ -144,7 +142,6 @@ const context = createContext<{ config: ReturnType["data"] mutatePending: (action: PendingAction, inboxID: string) => Promise pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined - jumpToBackgroundTool: (target: BackgroundToolTarget, beforeMessageID: string) => void }>() function use() { @@ -671,25 +668,6 @@ export function Session(props: { alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0))) }) - const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => { - if (firstJump()) clearMessageNavigation() - const jump = () => { - const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID) - if (index === -1) { - if (data.session.message.more(route.sessionID)) prependHistory(0, jump) - return - } - const id = sessionRowID(rows[index]!, boundaries()[index]) - if (!id) return - ensureAllRows(() => { - const child = scroll.getRenderable(id) - if (!child) return - alignMessage(id, Math.max(0, scroll.scrollTop + child.y - scroll.viewport.y - 1)) - }) - } - jump() - } - function toBottom() { clearMessageNavigation() ensureAllRowsPending = undefined @@ -1310,7 +1288,6 @@ export function Session(props: { config, mutatePending, pendingDelivery: (inboxID) => pendingDeliveries().get(inboxID), - jumpToBackgroundTool, }} > @@ -2057,15 +2034,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const ctx = use() const theme = useTheme() - const renderer = useRenderer() - const [hover, setHover] = createSignal(false) const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined) const source = () => stringValue(metadata()?.source) - const target = createMemo(() => { - if (source() !== "shell") return - const id = stringValue(metadata()?.shellID) ?? stringValue(metadata()?.jobID) - return id ? { source: "shell", id } : undefined - }) const completion = () => source() === "subagent" || source() === "shell" const state = () => stringValue(metadata()?.state) const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent")) @@ -2083,7 +2053,6 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const heading = () => `${state() === "completed" ? "↳" : "!"} ${actor()} ${status()}` const suffix = () => Locale.truncateWidth(` · ${description()}`, Math.max(0, ctx.width - 3 - stringWidth(heading()))) const color = () => { - if (hover()) return theme.text.action.secondary.hovered if (state() === "error") return theme.text.feedback.error.default if (state() === "cancelled") return theme.text.feedback.warning.default return theme.text.feedback.info.default @@ -2097,19 +2066,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { } > - { - if (target()) setHover(true) - }} - onMouseOut={() => setHover(false)} - onMouseUp={() => { - const item = target() - if (!item || renderer.getSelection()?.getSelectedText()) return - ctx.jumpToBackgroundTool(item, props.message.id) - }} - > + {heading()} {suffix()} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 0d3c773c47c..b565268db57 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -35,8 +35,6 @@ export type SessionRow = | { type: "assistant-footer"; messageID: string } | { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage } -export type BackgroundToolTarget = { source: "shell"; id: string } - export function createSessionRows(sessionID: Accessor, onSynced?: (sessionID: string) => void) { const data = useData() const client = useClient() @@ -409,29 +407,6 @@ export function sessionRowID(row: SessionRow, boundaryID?: string) { if (row.type === "part") return `session-part:${row.ref.messageID}:${row.ref.partID}` } -export function backgroundToolRowIndex( - rows: SessionRow[], - messages: SessionMessageInfo[], - target: BackgroundToolTarget, - beforeMessageID: string, -) { - const byID = new Map(messages.map((message) => [message.id, message])) - const end = rows.findIndex((row) => row.type === "message" && row.messageID === beforeMessageID) - return rows.slice(0, end === -1 ? rows.length : end).findLastIndex((row) => { - if (row.type !== "part") return false - if (row.ref.partID === target.id) return true - const message = byID.get(row.ref.messageID) - if (message?.type !== "assistant") return false - const part = resolvePart(message, row.ref.partID) - return ( - part?.type === "tool" && - part.name.toLowerCase() === "shell" && - part.state.status !== "streaming" && - part.state.metadata?.shellID === target.id - ) - }) -} - function rowBoundaryMessageID(row: SessionRow, messages: Map) { if (row.type === "message") { const message = messages.get(row.messageID) diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 2f29fb494aa..2a084443176 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,9 +1,8 @@ import { expect, test } from "bun:test" -import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { createMemo, createRoot } from "solid-js" import { createStore } from "solid-js/store" import { - backgroundToolRowIndex, cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, @@ -259,63 +258,6 @@ test("assigns stable IDs to tool rows for direct navigation", () => { ]) }) -test("finds background tool launch rows for completion navigation", () => { - const messages: SessionMessageInfo[] = [ - assistant("assistant-1", [ - { - type: "tool", - id: "shell-1", - name: "shell", - state: completed({ shellID: "sh_first", status: "running" }), - time: { created: 1 }, - }, - ]), - assistant("assistant-2", [ - { - type: "tool", - id: "subagent-1", - name: "subagent", - state: completed({ sessionID: "child-1", status: "running" }), - time: { created: 2 }, - }, - ]), - { - type: "synthetic", - id: "completion-1", - text: "First background run completed", - description: "First run", - time: { created: 3 }, - }, - assistant("assistant-3", [ - { - type: "tool", - id: "subagent-2", - name: "subagent", - state: completed({ sessionID: "child-1", status: "running" }), - time: { created: 4 }, - }, - { - type: "tool", - id: "subagent-foreground", - name: "subagent", - state: completed({ sessionID: "child-1", status: "completed" }), - time: { created: 5 }, - }, - ]), - { - type: "synthetic", - id: "completion-2", - text: "Second background run completed", - description: "Second run", - time: { created: 6 }, - }, - ] - const rows = reduceSessionRows(messages) - - expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "shell-1" }, "completion-2")).toBe(0) - expect(backgroundToolRowIndex(rows, messages, { source: "shell", id: "sh_first" }, "completion-2")).toBe(0) -}) - test("groups exploration parts across assistant messages until a delimiter", () => { const messages: SessionMessageInfo[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, @@ -620,14 +562,3 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses function pending() { return { status: "streaming" as const, input: "" } } - -function completed( - metadata: Record, -): Extract { - return { - status: "completed", - input: {}, - content: [{ type: "text", text: "Background" }], - metadata, - } -} diff --git a/packages/tui/test/completion-notices.test.tsx b/packages/tui/test/completion-notices.test.tsx new file mode 100644 index 00000000000..dfce19b0cd2 --- /dev/null +++ b/packages/tui/test/completion-notices.test.tsx @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test" +import { type Renderable, ScrollBoxRenderable } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode-ai/util/global" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" + +test.each([40, 120])("completion notices do not navigate at width %s", async (width) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width, height: 36, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const session = { + id: "ses_notices", + title: "Completion notices", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const notices = [ + { source: "shell", state: "completed", shellID: "shell-1", label: "Shell finished", description: "Done" }, + { + source: "shell", + state: "error", + jobID: "shell-1", + label: "Shell failed", + description: "Long command ".repeat(30), + }, + { + source: "shell", + state: "cancelled", + shellID: "shell-1", + label: "Shell cancelled", + description: "Cancelled command", + }, + { source: "subagent", state: "completed", sessionID: "child-1", label: "Subagent finished", description: "Done" }, + { source: "subagent", state: "error", sessionID: "child-1", label: "Subagent failed", description: "Failed" }, + { + source: "subagent", + state: "cancelled", + sessionID: "child-1", + label: "Subagent cancelled", + description: "Cancelled", + }, + ] + const messages = [ + { id: "user-0", type: "user", text: "Run background tasks", time: { created: 0 } }, + { + id: "assistant-0", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "test" }, + content: [ + { + type: "tool", + id: "shell-1", + name: "shell", + state: { + status: "completed", + input: { command: "echo done", background: true }, + content: [{ type: "text", text: "Running" }], + metadata: { shellID: "shell-1" }, + }, + time: { created: 1, completed: 2 }, + }, + ], + time: { created: 1, completed: 2 }, + }, + ...Array.from({ length: 20 }, (_, index) => ({ + id: `history-${index}`, + type: "user", + text: `History message ${index}`, + time: { created: index + 3 }, + })), + ...notices.map(({ label, description, ...metadata }, index) => ({ + id: `notice-${index}`, + type: "synthetic", + text: label, + description, + metadata, + time: { created: index + 30 }, + })), + ] + const calls = createFetch((url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} }) + if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] }) + if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] }) + return undefined + }, createEventStream()) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { + get: async () => ({ animations: false, tabs: { enabled: false } }), + update: async () => ({}), + }, + packages: { prepare: async () => ({ directory: "" }) }, + args: { sessionID: session.id }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await setup.waitForFrame((frame) => frame.includes("Subagent cancelled")) + await setup.waitForVisualIdle() + const find = (root: Renderable): ScrollBoxRenderable | undefined => + root instanceof ScrollBoxRenderable && root.getRenderable("history-19") + ? root + : root.getChildren().map(find).find(Boolean) + const scroll = find(setup.renderer.root) + if (!scroll) throw new Error("Session scrollbox not found") + expect(scroll.scrollTop).toBeGreaterThan(0) + const before = scroll.scrollTop + for (const notice of notices) { + const lines = setup.captureCharFrame().split("\n") + const y = lines.findIndex((line) => line.includes(notice.label)) + expect(y).toBeGreaterThanOrEqual(0) + const x = lines[y].indexOf(notice.label) + await setup.mockMouse.click(x + 1, y) + await setup.waitForVisualIdle() + expect(scroll.scrollTop).toBe(before) + expect(setup.renderer.currentFocusedRenderable?.id).toBe(scroll.id) + expect(setup.captureCharFrame()).toContain(notice.label) + } + } finally { + setup.renderer.destroy() + await task + await server.stop() + } +})