Compare commits

...

4 Commits

Author SHA1 Message Date
Sebastian Herrlinger
32728ff684 carry over if no other draft 2026-04-15 18:13:02 +02:00
Sebastian Herrlinger
8e010e32ae simplify 2026-04-15 18:11:31 +02:00
Sebastian Herrlinger
c3e4352c21 fix structured cloning 2026-04-15 17:27:37 +02:00
Sebastian Herrlinger
6cabcc84a8 initial 2026-04-15 16:53:45 +02:00
9 changed files with 319 additions and 144 deletions

View File

@@ -55,7 +55,7 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { homeScope, PromptRefProvider, sessionScope, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
@@ -195,6 +195,7 @@ export function tui(input: {
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig()
const route = useRoute()
const project = useProject()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
@@ -416,12 +417,15 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
const currentPrompt =
route.data.type === "session" ? promptRef.current(sessionScope(route.data.sessionID)) : undefined
if (currentPrompt) {
promptRef.apply(homeScope(project.workspace.current()), currentPrompt)
}
route.navigate({
type: "home",
initialPrompt: currentPrompt,
})
dialog.clear()
},

View File

@@ -25,6 +25,11 @@ export type PromptInfo = {
)[]
}
export type PromptDraft = {
prompt: PromptInfo
cursor: number
}
const MAX_HISTORY_ENTRIES = 50
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({

View File

@@ -11,10 +11,11 @@ import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { homeScope, sessionScope, usePromptRef } from "@tui/context/prompt"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { usePromptHistory, type PromptDraft, type PromptInfo } from "./history"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
@@ -57,6 +58,8 @@ export type PromptProps = {
export type PromptRef = {
focused: boolean
current: PromptInfo
snapshot(): PromptDraft
restore(draft: PromptDraft): void
set(prompt: PromptInfo): void
reset(): void
blur(): void
@@ -85,6 +88,7 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const sync = useSync()
const dialog = useDialog()
const promptState = usePromptRef()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
@@ -98,6 +102,14 @@ export function Prompt(props: PromptProps) {
const [auto, setAuto] = createSignal<AutocompleteRef>()
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
const scope = createMemo(() => {
if (props.sessionID) return sessionScope(props.sessionID)
if (props.workspaceID !== undefined) return homeScope(props.workspaceID)
if (route.data.type === "session") return sessionScope(route.data.sessionID)
if (route.data.type === "plugin") return `plugin:${route.data.id}`
return homeScope()
})
let active: string | undefined
function promptModelWarning() {
toast.show({
@@ -177,6 +189,7 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: new Map(),
interrupt: 0,
})
const [ready, setReady] = createSignal(false)
createEffect(
on(
@@ -219,8 +232,7 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
input.clear()
ref.reset()
dialog.clear()
},
},
@@ -397,12 +409,70 @@ export function Prompt(props: PromptProps) {
]
})
function clearPrompt(mode: "keep" | "normal" = "keep") {
input.extmarks.clear()
input.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
if (mode === "normal") {
setStore("mode", "normal")
}
}
function restorePrompt(draft: PromptDraft) {
const next = structuredClone(unwrap(draft))
input.setText(next.prompt.input)
setStore("mode", next.prompt.mode ?? "normal")
setStore("prompt", {
input: next.prompt.input,
parts: next.prompt.parts,
})
restoreExtmarksFromParts(next.prompt.parts)
input.cursorOffset = next.cursor
}
function snapshot() {
const value = input && !input.isDestroyed ? input.plainText : store.prompt.input
if (input && !input.isDestroyed) {
if (value !== store.prompt.input) {
setStore("prompt", "input", value)
}
syncExtmarksWithPromptParts()
}
return {
prompt: {
input: value,
mode: store.mode,
parts: structuredClone(unwrap(store.prompt.parts)),
},
cursor: input && !input.isDestroyed ? input.cursorOffset : Bun.stringWidth(value),
} satisfies PromptDraft
}
const ref: PromptRef = {
get focused() {
return input.focused
},
get current() {
return store.prompt
return {
input: store.prompt.input,
mode: store.mode,
parts: store.prompt.parts,
}
},
snapshot() {
return snapshot()
},
restore(draft) {
restorePrompt(draft)
if (active) {
promptState.save(active, draft)
}
},
focus() {
input.focus()
@@ -411,26 +481,67 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
input.setText(prompt.input)
setStore("prompt", prompt)
restoreExtmarksFromParts(prompt.parts)
input.gotoBufferEnd()
ref.restore({
prompt: structuredClone(unwrap(prompt)),
cursor: Bun.stringWidth(prompt.input),
})
},
reset() {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
clearPrompt()
if (active) {
promptState.drop(active)
}
},
submit() {
submit()
},
}
createEffect(() => {
if (!ready()) return
const next = scope()
if (active === next) return
const prev = active
const prevDraft = prev ? snapshot() : undefined
if (prev) {
promptState.save(prev, prevDraft!)
promptState.bind(prev, undefined)
}
active = next
promptState.bind(next, ref)
const draft = promptState.load(next)
if (draft) {
ref.restore(draft)
return
}
const carry =
prev &&
prev.startsWith("session:") &&
next.startsWith("session:") &&
route.data.type === "session" &&
props.sessionID === route.data.sessionID &&
!route.data.initialPrompt &&
prevDraft &&
(prevDraft.prompt.input || prevDraft.prompt.parts.length > 0)
if (carry) {
ref.restore(prevDraft)
return
}
clearPrompt("normal")
})
onCleanup(() => {
if (active) {
promptState.save(active, snapshot())
promptState.bind(active, undefined)
}
props.ref?.(undefined)
})
@@ -538,17 +649,15 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
enabled: !!store.prompt.input || store.prompt.parts.length > 0,
onSelect: (dialog) => {
if (!store.prompt.input) return
const prompt = snapshot()
if (!prompt.prompt.input && prompt.prompt.parts.length === 0) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
input: prompt.prompt.input,
parts: prompt.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
ref.reset()
dialog.clear()
},
},
@@ -560,10 +669,7 @@ export function Prompt(props: PromptProps) {
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
ref.set({ input: entry.input, parts: entry.parts })
}
dialog.clear()
},
@@ -577,10 +683,7 @@ export function Prompt(props: PromptProps) {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
ref.set({ input: entry.input, parts: entry.parts })
}}
/>
))
@@ -589,17 +692,13 @@ export function Prompt(props: PromptProps) {
])
async function submit() {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
const prompt = snapshot()
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (!prompt.prompt.input) return
const trimmed = prompt.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
return
@@ -631,7 +730,7 @@ export function Prompt(props: PromptProps) {
}
const messageID = MessageID.ascending()
let inputText = store.prompt.input
let inputText = prompt.prompt.input
// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
@@ -640,7 +739,7 @@ export function Prompt(props: PromptProps) {
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
const part = prompt.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
@@ -650,13 +749,13 @@ export function Prompt(props: PromptProps) {
}
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
const nonTextParts = prompt.prompt.parts.filter((part) => part.type !== "text")
// Capture mode before it gets reset
const currentMode = store.mode
const currentMode = prompt.prompt.mode ?? "normal"
const variant = local.model.variant.current()
if (store.mode === "shell") {
if (currentMode === "shell") {
sdk.client.session.shell({
sessionID,
agent: local.agent.current().name,
@@ -717,16 +816,11 @@ export function Prompt(props: PromptProps) {
})
.catch(() => {})
}
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
history.append(prompt.prompt)
clearPrompt()
if (active) {
promptState.drop(active)
}
props.onSubmit?.()
// temporary hack to make sure the message is sent
@@ -737,7 +831,6 @@ export function Prompt(props: PromptProps) {
sessionID,
})
}, 50)
input.clear()
}
const exit = useExit()
@@ -944,14 +1037,8 @@ export function Prompt(props: PromptProps) {
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
if (keybind.match("input_clear", e) && (store.prompt.input !== "" || store.prompt.parts.length > 0)) {
ref.reset()
return
}
if (keybind.match("app_exit", e)) {
@@ -1090,6 +1177,7 @@ export function Prompt(props: PromptProps) {
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
setReady(true)
props.ref?.(ref)
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly

View File

@@ -1,17 +1,85 @@
import { createSimpleContext } from "./helper"
import { unwrap } from "solid-js/store"
import type { PromptRef } from "../component/prompt"
import type { PromptDraft, PromptInfo } from "../component/prompt/history"
export function homeScope(workspaceID?: string) {
if (!workspaceID) return "home"
return `home:${workspaceID}`
}
export function sessionScope(sessionID: string) {
return `session:${sessionID}`
}
function clone<T>(value: T) {
return structuredClone(unwrap(value))
}
function draft(input: PromptInfo | PromptDraft) {
if ("prompt" in input) return clone(input)
return {
prompt: clone(input),
cursor: Bun.stringWidth(input.input),
} satisfies PromptDraft
}
function empty(input?: PromptInfo | PromptDraft) {
if (!input) return true
const prompt = "prompt" in input ? input.prompt : input
if (prompt.input) return false
return prompt.parts.length === 0
}
export const { use: usePromptRef, provider: PromptRefProvider } = createSimpleContext({
name: "PromptRef",
init: () => {
let current: PromptRef | undefined
const drafts = new Map<string, PromptDraft>()
let live: { scope: string; ref: PromptRef } | undefined
function load(scope: string) {
const value = drafts.get(scope)
if (!value) return
return clone(value)
}
function save(scope: string, input: PromptInfo | PromptDraft) {
if (empty(input)) {
drafts.delete(scope)
return
}
drafts.set(scope, draft(input))
}
return {
get current() {
return current
current(scope: string) {
if (live?.scope === scope) {
const value = live.ref.snapshot()
if (!empty(value)) return value
return
}
return load(scope)
},
set(ref: PromptRef | undefined) {
current = ref
load,
save,
apply(scope: string, input: PromptInfo | PromptDraft) {
const value = draft(input)
save(scope, value)
if (live?.scope !== scope) return
live.ref.restore(value)
},
drop(scope: string) {
drafts.delete(scope)
},
bind(scope: string, ref: PromptRef | undefined) {
if (!ref) {
if (live?.scope === scope) live = undefined
return
}
live = { scope, ref }
},
}
},

View File

@@ -1,4 +1,4 @@
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
@@ -37,7 +37,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
setStore(route)
setStore(reconcile(route))
},
}
},

View File

@@ -1,45 +1,63 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal } from "solid-js"
import { Logo } from "../component/logo"
import { useProject } from "../context/project"
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"
import { homeScope, usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false
let startup = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
shell: ["ls -la", "git status", "pwd"],
}
type Ref = Pick<PromptRef, "current" | "submit">
export function Home() {
const sync = useSync()
const project = useProject()
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
const [ref, setRef] = createSignal<Ref | undefined>()
const args = useArgs()
const local = useLocal()
const scope = createMemo(() => homeScope(project.workspace.current()))
let sent = false
let seeded: string | undefined
const bind = (r: PromptRef | undefined) => {
const bind = (r: Ref | undefined) => {
setRef(r)
promptRef.set(r)
if (once || !r) return
if (route.initialPrompt) {
r.set(route.initialPrompt)
once = true
}
createEffect(() => {
const key = scope()
if (seeded === key) return
if (promptRef.current(key)) {
if (route.initialPrompt) {
seeded = key
startup = true
}
return
}
if (!args.prompt) return
r.set({ input: args.prompt, parts: [] })
once = true
}
if (route.initialPrompt) {
promptRef.apply(key, route.initialPrompt)
seeded = key
startup = true
return
}
if (startup || !args.prompt) return
promptRef.apply(key, { input: args.prompt, parts: [] })
startup = true
})
// Wait for sync and model store to be ready before auto-submitting --prompt
createEffect(() => {

View File

@@ -4,18 +4,16 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import { sessionScope, usePromptRef } from "@tui/context/prompt"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogMessage(props: {
messageID: string
sessionID: string
setPrompt?: (prompt: PromptInfo) => void
}) {
export function DialogMessage(props: { messageID: string; sessionID: string }) {
const sync = useSync()
const sdk = useSDK()
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
const route = useRoute()
const promptRef = usePromptRef()
return (
<DialogSelect
@@ -34,20 +32,18 @@ export function DialogMessage(props: {
messageID: msg.id,
})
if (props.setPrompt) {
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
props.setPrompt(promptInfo)
}
const parts = sync.data.part[msg.id]
const promptInfo = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
promptRef.apply(sessionScope(props.sessionID), promptInfo)
dialog.clear()
},
@@ -90,7 +86,7 @@ export function DialogMessage(props: {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },

View File

@@ -5,13 +5,8 @@ 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
}) {
export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
const dialog = useDialog()
@@ -33,9 +28,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

@@ -22,7 +22,7 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { Prompt } from "@tui/component/prompt"
import type {
AssistantMessage,
Part,
@@ -72,7 +72,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionScope, usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
@@ -87,6 +87,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry"
import { strip } from "../../component/prompt/part"
addDefaultParsers(parsers.parsers)
@@ -142,6 +143,8 @@ export function Session() {
})
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
const scope = createMemo(() => sessionScope(route.sessionID))
let seeded: string | undefined
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -199,8 +202,6 @@ export function Session() {
})
})
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
@@ -218,15 +219,23 @@ export function Session() {
}
})
createEffect(() => {
const key = scope()
if (seeded === key) return
if (promptRef.current(key)) {
if (route.initialPrompt) seeded = key
return
}
if (!route.initialPrompt) return
promptRef.apply(key, route.initialPrompt)
seeded = key
})
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
promptRef.set(r)
if (seeded || !route.initialPrompt || !r) return
seeded = true
r.set(route.initialPrompt)
}
const bind = () => {}
const keybind = useKeybind()
const dialog = useDialog()
const renderer = useRenderer()
@@ -438,7 +447,6 @@ export function Session() {
if (child) scroll.scrollBy(child.y - scroll.y - 1)
}}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
},
@@ -539,13 +547,14 @@ export function Session() {
toBottom()
})
const parts = sync.data.part[message.id]
prompt?.set(
promptRef.apply(
scope(),
parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
if (part.type === "file") agg.parts.push(strip(part))
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
@@ -572,7 +581,7 @@ export function Session() {
sdk.client.session.unrevert({
sessionID: route.sessionID,
})
prompt?.set({ input: "", parts: [] })
promptRef.apply(scope(), { input: "", parts: [] })
return
}
sdk.client.session.revert({
@@ -1149,13 +1158,7 @@ export function Session() {
index={index()}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={message.id}
sessionID={route.sessionID}
setPrompt={(promptInfo) => prompt?.set(promptInfo)}
/>
))
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={route.sessionID} />)
}}
message={message as UserMessage}
parts={sync.data.part[message.id] ?? []}