feat(tui): improve session fork handling

This commit is contained in:
Dax Raad 2026-07-06 18:58:34 -04:00
commit 96717c1a8c
24 changed files with 335 additions and 387 deletions

View file

@ -559,13 +559,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
route.navigate({ type: "session", sessionID: match })
return
}
void sdk.client.session.fork({ sessionID: match }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
return
}
toast.show({ message: "Failed to fork session", variant: "error" })
})
void sdk.api.session
.fork({ sessionID: match })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
})
.catch(toast.error)
})
@ -577,13 +574,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
createEffect(() => {
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
forked = true
void sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
if (result.data?.id) {
route.navigate({ type: "session", sessionID: result.data.id })
} else {
toast.show({ message: "Failed to fork session", variant: "error" })
}
})
void sdk.api.session
.fork({ sessionID: args.sessionID })
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
.catch(toast.error)
})
const connected = useConnected()

View file

@ -1,88 +0,0 @@
import { createMemo, onMount } from "solid-js"
import { useSync } from "../../context/sync"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { useSDK } from "../../context/sdk"
import { useRoute } from "../../context/route"
import { useDialog, type DialogContext } from "../../ui/dialog"
import { emptyPrompt, type PromptInfo } from "../../component/prompt/history"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
onMount(() => {
dialog.setSize("large")
})
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()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
(x) => x.type === "text" && !x.synthetic && !x.ignored,
) as TextPart
if (!part) continue
result.push({
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(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.text += part.text
}
if (part.type === "file") {
const files = (agg.files ??= [])
files.push({
uri: part.url,
name: part.filename,
mention: part.source?.text
? {
start: part.source.text.start,
end: part.source.text.end,
text: part.source.text.value,
}
: undefined,
})
}
return agg
},
emptyPrompt() as PromptInfo,
)
route.navigate({
sessionID: forked.data!.id,
type: "session",
prompt,
})
dialog.clear()
},
})
}
return [fullSession, ...result.reverse()]
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}

View file

@ -0,0 +1,89 @@
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useData } from "../../context/data"
import { useRoute } from "../../context/route"
import { useSDK } from "../../context/sdk"
import { Spinner } from "../../component/spinner"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { useToast } from "../../ui/toast"
import { errorMessage } from "../../util/error"
import { Locale } from "../../util/locale"
export function DialogFork(props: { sessionID: string; messageID?: string; onMove?: (messageID?: string) => void }) {
const data = useData()
const dialog = useDialog()
const sdk = useSDK()
const route = useRoute()
const toast = useToast()
const [pending, setPending] = createSignal(false)
const fork = async (messageID?: string) => {
setPending(true)
const result = await sdk.api.session.fork({ sessionID: props.sessionID, messageID }).catch((error) => {
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
return undefined
})
if (!result) return dialog.clear()
const message = messageID ? data.session.message.get(props.sessionID, messageID) : undefined
route.navigate({
sessionID: result.id,
type: "session",
prompt:
message?.type === "user"
? {
text: message.text,
files: message.files?.map((file) => ({
uri: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
name: file.name,
description: file.description,
mention: file.mention,
})),
agents: structuredClone(message.agents ?? []),
pasted: [],
}
: undefined,
})
dialog.clear()
toast.show({ message: "Forked session", variant: "success", duration: 4000 })
}
onMount(() => {
dialog.setSize("large")
if (props.messageID) void fork(props.messageID)
})
const options = createMemo((): DialogSelectOption<string | undefined>[] => [
{
title: "Full session",
value: undefined,
onSelect: () => fork(),
},
...data.session.message
.list(props.sessionID)
.filter((message) => message.type === "user")
.toReversed()
.map((message) => ({
title: message.text.replace(/\n/g, " "),
value: message.id,
footer: Locale.time(message.time.created),
onSelect: () => fork(message.id),
})),
])
return (
<Show
when={!pending()}
fallback={
<box paddingLeft={2} paddingRight={2} paddingBottom={1}>
<Spinner>Forking session...</Spinner>
</box>
}
>
<DialogSelect
onMove={(option) => props.onMove?.(option.value)}
title="Fork session"
options={options()}
/>
</Show>
)
}

View file

@ -5,8 +5,9 @@ import { useClipboard } from "../../context/clipboard"
import { useToast } from "../../ui/toast"
import { useSDK } from "../../context/sdk"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
export function DialogMessage(props: { messageID: string; sessionID: string; setPrompt?: unknown }) {
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const data = useData()
const clipboard = useClipboard()
const toast = useToast()
@ -55,8 +56,9 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set
value: "session.fork",
description: "create a new session",
onSelect: (dialog) => {
toast.show({ message: "Forking is not implemented for V2 sessions yet", variant: "error", duration: 5000 })
dialog.clear()
const value = message()
if (!value || value.type !== "user") return
dialog.replace(() => <DialogFork sessionID={props.sessionID} messageID={props.messageID} />)
},
},
]}

View file

@ -5,12 +5,10 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
import { Locale } from "../../util/locale"
import { DialogMessage } from "./dialog-message"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "../../component/prompt/history"
export function DialogTimeline(props: {
sessionID: string
onMove: (messageID: string) => void
setPrompt?: (prompt: PromptInfo) => void
}) {
const sync = useSync()
const dialog = useDialog()
@ -33,9 +31,7 @@ export function DialogTimeline(props: {
value: message.id,
footer: Locale.time(message.time.created),
onSelect: (dialog) => {
dialog.replace(() => (
<DialogMessage messageID={message.id} sessionID={props.sessionID} setPrompt={props.setPrompt} />
))
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
},
})
}

View file

@ -46,6 +46,7 @@ import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { Sidebar } from "./sidebar"
import { Composer } from "./composer"
import { filetype } from "../../util/filetype"
@ -370,7 +371,18 @@ export function Session() {
value: "session.fork",
category: "Session",
slash: { name: "fork" },
run: () => unavailable("Forking"),
run: () => {
dialog.replace(() => (
<DialogFork
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
/>
))
},
},
{
title: "Compact session",
@ -1405,7 +1417,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? theme.textMuted : color()}
borderColor={queued() ? theme.border : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<box