From 199ebbd76e93fad740dd9b8b043fe8f3d326e0bd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 22 Jul 2026 03:49:38 +0000 Subject: [PATCH] fix(tui): prevent duplicate message forks --- .../tui/src/routes/session/dialog-fork.tsx | 2 +- .../tui/test/cli/tui/dialog-fork.test.tsx | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/tui/test/cli/tui/dialog-fork.test.tsx diff --git a/packages/tui/src/routes/session/dialog-fork.tsx b/packages/tui/src/routes/session/dialog-fork.tsx index 428ef80ecf..4a8ac9ed8f 100644 --- a/packages/tui/src/routes/session/dialog-fork.tsx +++ b/packages/tui/src/routes/session/dialog-fork.tsx @@ -16,7 +16,7 @@ export function DialogFork(props: { sessionID: string; messageID?: string; onMov const client = useClient() const route = useRoute() const toast = useToast() - const [pending, setPending] = createSignal(false) + const [pending, setPending] = createSignal(!!props.messageID) const fork = async (messageID?: string) => { setPending(true) diff --git a/packages/tui/test/cli/tui/dialog-fork.test.tsx b/packages/tui/test/cli/tui/dialog-fork.test.tsx new file mode 100644 index 0000000000..f8487dc603 --- /dev/null +++ b/packages/tui/test/cli/tui/dialog-fork.test.tsx @@ -0,0 +1,108 @@ +/** @jsxImportSource @opentui/solid */ +import { InputRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onMount } from "solid-js" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { ClipboardProvider } from "../../../src/context/clipboard" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { RouteProvider } from "../../../src/context/route" +import { ThemeProvider } from "../../../src/context/theme" +import { DialogProvider, useDialog } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { tmpdir } from "../../fixture/fixture" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("forks a message exactly once from its action dialog", async () => { + await using tmp = await tmpdir() + const state = path.join(tmp.path, "state") + await mkdir(state, { recursive: true }) + const bodies: unknown[] = [] + let finishFork = () => {} + const forkResponse = new Promise((resolve) => { + finishFork = resolve + }) + const events = createEventStream() + const transport = createFetch(async (url, request) => { + if (url.pathname === "/api/session/dummy/message") + return json({ data: [{ id: "message", type: "user", text: "fork me", time: { created: 1 } }], cursor: {} }) + if (request.method === "POST" && url.pathname === "/api/session/dummy/fork") { + bodies.push(await request.json()) + await forkResponse + return json({ + data: { + id: "fork", + title: "Forked session", + projectID: "project", + location: { directory: tmp.path }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + }) + } + }, events) + const config = createTuiResolvedConfig() + const { DialogMessage } = await import("../../../src/routes/session/dialog-message") + + function Fixture() { + const data = useData() + const dialog = useDialog() + onMount(() => { + void data.session.message.sync("dummy").then(() => + dialog.replace(() => ), + ) + }) + return null + } + + function Harness() { + return ( + + + + + + + + Promise.resolve({}) }}> + + + + + + + + + + + + + + ) + } + + const app = await testRender(() => , { width: 80, height: 20, kittyKeyboard: true }) + try { + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("Message Actions")) + await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) + await app.mockInput.typeText("Fork") + app.mockInput.pressEnter() + await app.waitFor(() => bodies.length > 0) + await app.renderOnce() + await Promise.resolve() + await app.renderOnce() + + expect(bodies).toEqual([{ messageID: "message" }]) + } finally { + finishFork() + app.renderer.destroy() + } +})