feat(tui): improve session fork handling
This commit is contained in:
parent
e312f0d775
commit
96717c1a8c
24 changed files with 335 additions and 387 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()} />
|
||||
}
|
||||
89
packages/tui/src/routes/session/dialog-fork.tsx
Normal file
89
packages/tui/src/routes/session/dialog-fork.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue