Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 199ebbd76e fix(tui): prevent duplicate message forks 2026-07-22 04:04:16 +00:00
2 changed files with 109 additions and 1 deletions
@@ -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)
@@ -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<void>((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(() => <DialogMessage sessionID="dummy" messageID="message" />),
)
})
return null
}
function Harness() {
return (
<TestTuiContexts directory={tmp.path} paths={{ home: tmp.path, state, worktree: tmp.path }}>
<ClipboardProvider value={{}}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "dummy" }}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
<ToastProvider>
<DialogProvider>
<Fixture />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</RouteProvider>
</DataProvider>
</ClientProvider>
</Keymap.Provider>
</ConfigProvider>
</ClipboardProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { 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()
}
})