dialog-fork.tsx 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import { Component, createMemo } from "solid-js"
  2. import { useNavigate, useParams } from "@solidjs/router"
  3. import { useSync } from "@/context/sync"
  4. import { useSDK } from "@/context/sdk"
  5. import { usePrompt } from "@/context/prompt"
  6. import { useDialog } from "@opencode-ai/ui/context/dialog"
  7. import { Dialog } from "@opencode-ai/ui/dialog"
  8. import { List } from "@opencode-ai/ui/list"
  9. import { extractPromptFromParts } from "@/utils/prompt"
  10. import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
  11. import { base64Encode } from "@opencode-ai/util/encode"
  12. import { useLanguage } from "@/context/language"
  13. interface ForkableMessage {
  14. id: string
  15. text: string
  16. time: string
  17. }
  18. function formatTime(date: Date): string {
  19. return date.toLocaleTimeString(undefined, { timeStyle: "short" })
  20. }
  21. export const DialogFork: Component = () => {
  22. const params = useParams()
  23. const navigate = useNavigate()
  24. const sync = useSync()
  25. const sdk = useSDK()
  26. const prompt = usePrompt()
  27. const dialog = useDialog()
  28. const language = useLanguage()
  29. const messages = createMemo((): ForkableMessage[] => {
  30. const sessionID = params.id
  31. if (!sessionID) return []
  32. const msgs = sync.data.message[sessionID] ?? []
  33. const result: ForkableMessage[] = []
  34. for (const message of msgs) {
  35. if (message.role !== "user") continue
  36. const parts = sync.data.part[message.id] ?? []
  37. const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
  38. if (!textPart) continue
  39. result.push({
  40. id: message.id,
  41. text: textPart.text.replace(/\n/g, " ").slice(0, 200),
  42. time: formatTime(new Date(message.time.created)),
  43. })
  44. }
  45. return result.reverse()
  46. })
  47. const handleSelect = (item: ForkableMessage | undefined) => {
  48. if (!item) return
  49. const sessionID = params.id
  50. if (!sessionID) return
  51. const parts = sync.data.part[item.id] ?? []
  52. const restored = extractPromptFromParts(parts, {
  53. directory: sdk.directory,
  54. attachmentName: language.t("common.attachment"),
  55. })
  56. dialog.close()
  57. sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
  58. if (!forked.data) return
  59. navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
  60. requestAnimationFrame(() => {
  61. prompt.set(restored)
  62. })
  63. })
  64. }
  65. return (
  66. <Dialog title={language.t("command.session.fork")}>
  67. <List
  68. class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
  69. search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
  70. emptyMessage={language.t("dialog.fork.empty")}
  71. key={(x) => x.id}
  72. items={messages}
  73. filterKeys={["text"]}
  74. onSelect={handleSelect}
  75. >
  76. {(item) => (
  77. <div class="w-full flex items-center gap-2">
  78. <span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
  79. {item.text}
  80. </span>
  81. <span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
  82. {item.time}
  83. </span>
  84. </div>
  85. )}
  86. </List>
  87. </Dialog>
  88. )
  89. }