feat(tui): show progress and confirmation when forking a session

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 <Toast/> once in the app shell instead of per-route, so a toast
fired around route.navigate survives the route remount.
This commit is contained in:
Kit Langton
2026-05-27 21:53:27 -04:00
parent c0bc020ad6
commit 3b7eafc86e
4 changed files with 30 additions and 26 deletions
+3 -1
View File
@@ -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<string[]> }) {
<TuiPluginRuntime.Slot name="app_bottom" />
</box>
<TuiPluginRuntime.Slot name="app" />
{/* One Toast for the whole app, not per-route, so toasts survive route.navigate */}
<Toast />
</Show>
<StartupLoading ready={ready} />
</box>
@@ -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() {
</box>
<TuiPluginRuntime.Slot name="home_bottom" />
<box flexGrow={1} minHeight={0} />
<Toast />
</box>
<box width="100%" flexShrink={0}>
<TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
@@ -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(() => (
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
<Spinner>Forking session</Spinner>
</box>
))
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<string | undefined>[] => {
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<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
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)
},
})
}
@@ -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() {
</Show>
</box>
</Show>
<Toast />
</box>
<Show when={sidebarVisible()}>
<Switch>