From 3b7eafc86ec6d9d4e3c57e2c8dd8e59d93764066 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 27 May 2026 21:53:27 -0400 Subject: [PATCH] feat(tui): show progress and confirmation when forking a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forking gave no feedback: the dialog stayed open during the (potentially slow) fork, then silently navigated to a near-identical copy. Now the dialog shows a "Forking session…" spinner while the fork runs, then lands on the new session with a "Forked session" toast (or an error toast on failure). Mount once in the app shell instead of per-route, so a toast fired around route.navigate survives the route remount. --- packages/opencode/src/cli/cmd/tui/app.tsx | 4 +- .../opencode/src/cli/cmd/tui/routes/home.tsx | 2 - .../session/dialog-fork-from-timeline.tsx | 47 ++++++++++--------- .../src/cli/cmd/tui/routes/session/index.tsx | 3 +- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index b61ee80446c..48b29f53a6d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -50,7 +50,7 @@ import { FrecencyProvider } from "./component/prompt/frecency" import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" -import { ToastProvider, useToast } from "./ui/toast" +import { Toast, ToastProvider, useToast } from "./ui/toast" import { createExit, ExitProvider, useExit, type Exit } from "./context/exit" import { Session as SessionApi } from "@/session/session" import { TuiEvent } from "./event" @@ -1072,6 +1072,8 @@ function App(props: { onSnapshot?: () => Promise }) { + {/* One Toast for the whole app, not per-route, so toasts survive route.navigate */} + diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 16797573c00..6590305a14c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -2,7 +2,6 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { Logo } from "../component/logo" import { useSync } from "../context/sync" -import { Toast } from "../ui/toast" import { useArgs } from "../context/args" import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" @@ -83,7 +82,6 @@ export function Home() { - diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx index 98eea1f8220..d30d8432f83 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx @@ -5,6 +5,8 @@ import type { TextPart } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" +import { useToast } from "../../ui/toast" +import { Spinner } from "../../component/spinner" import { useDialog, type DialogContext } from "../../ui/dialog" import type { PromptInfo } from "@tui/component/prompt/history" import { strip } from "@tui/component/prompt/part" @@ -14,24 +16,37 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess const dialog = useDialog() const sdk = useSDK() const route = useRoute() + const toast = useToast() onMount(() => { dialog.setSize("large") }) + // Forking a large session can take a moment, so swap the dialog to a progress view instead of + // leaving it open (which looks frozen), then navigate to the fork and confirm with a toast. + const fork = async (dialog: DialogContext, messageID?: string, prompt?: PromptInfo) => { + dialog.replace(() => ( + + Forking session… + + )) + const forked = await sdk.client.session.fork({ sessionID: props.sessionID, messageID }) + if (!forked.data) { + toast.show({ variant: "error", message: "Failed to fork session" }) + dialog.clear() + return + } + route.navigate({ sessionID: forked.data.id, type: "session", prompt }) + dialog.clear() + toast.show({ variant: "success", message: "Forked session", duration: 4000 }) + } + const options = createMemo((): DialogSelectOption[] => { const messages = sync.data.message[props.sessionID] ?? [] const fullSession = { title: "Full session", value: undefined, - onSelect: async (dialog: DialogContext) => { - const forked = await sdk.client.session.fork({ sessionID: props.sessionID }) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - }) - dialog.clear() - }, + onSelect: fork, } satisfies DialogSelectOption const result = [] as DialogSelectOption[] for (const message of messages) { @@ -44,13 +59,8 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess title: part.text.replace(/\n/g, " "), value: message.id, footer: Locale.time(message.time.created), - onSelect: async (dialog) => { - const forked = await sdk.client.session.fork({ - sessionID: props.sessionID, - messageID: message.id, - }) - const parts = sync.data.part[message.id] ?? [] - const prompt = parts.reduce( + onSelect: (dialog) => { + const prompt = (sync.data.part[message.id] ?? []).reduce( (agg, part) => { if (part.type === "text") { if (!part.synthetic) agg.input += part.text @@ -60,12 +70,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess }, { input: "", parts: [] as PromptInfo["parts"] }, ) - route.navigate({ - sessionID: forked.data!.id, - type: "session", - prompt, - }) - dialog.clear() + return fork(dialog, message.id, prompt) }, }) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 270c11049e0..39b6b40aba3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -67,7 +67,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" import { errorMessage } from "@/util/error" -import { Toast, useToast } from "../../ui/toast" +import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import * as Editor from "../../util/editor" import stripAnsi from "strip-ansi" @@ -1263,7 +1263,6 @@ export function Session() { -