dialog-session-list.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { useDialog } from "@tui/ui/dialog"
  2. import { DialogSelect } from "@tui/ui/dialog-select"
  3. import { useRoute } from "@tui/context/route"
  4. import { useSync } from "@tui/context/sync"
  5. import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
  6. import { Locale } from "@/util/locale"
  7. import { useProject } from "@tui/context/project"
  8. import { useTheme } from "../context/theme"
  9. import { useSDK } from "../context/sdk"
  10. import { Flag } from "@opencode-ai/core/flag/flag"
  11. import { DialogSessionRename } from "./dialog-session-rename"
  12. import { createDebouncedSignal } from "../util/signal"
  13. import { useToast } from "../ui/toast"
  14. import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
  15. import { Spinner } from "./spinner"
  16. import { errorMessage } from "@/util/error"
  17. import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
  18. import { WorkspaceLabel } from "./workspace-label"
  19. import { useCommandShortcut } from "../keymap"
  20. export function DialogSessionList() {
  21. const dialog = useDialog()
  22. const route = useRoute()
  23. const sync = useSync()
  24. const project = useProject()
  25. const { theme } = useTheme()
  26. const sdk = useSDK()
  27. const toast = useToast()
  28. const [toDelete, setToDelete] = createSignal<string>()
  29. const [search, setSearch] = createDebouncedSignal("", 150)
  30. const deleteHint = useCommandShortcut("dialog.action.delete")
  31. const [searchResults, { refetch }] = createResource(
  32. () => ({ query: search(), filter: sync.session.query() }),
  33. async (input) => {
  34. if (!input.query) return undefined
  35. const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
  36. return result.data ?? []
  37. },
  38. )
  39. const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
  40. const sessions = createMemo(() => searchResults() ?? sync.data.session)
  41. function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
  42. const workspace = project.workspace.get(session.workspaceID!)
  43. const list = () => dialog.replace(() => <DialogSessionList />)
  44. const warp = async (selection: WorkspaceSelection) => {
  45. const workspaceID = await (async () => {
  46. if (selection.type === "none") return null
  47. if (selection.type === "existing") return selection.workspaceID
  48. const result = await sdk.client.experimental.workspace
  49. .create({ type: selection.workspaceType, branch: null })
  50. .catch(() => undefined)
  51. const workspace = result?.data
  52. if (!workspace) {
  53. toast.show({
  54. message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
  55. variant: "error",
  56. })
  57. return
  58. }
  59. await project.workspace.sync()
  60. return workspace.id
  61. })()
  62. if (workspaceID === undefined) return
  63. await warpWorkspaceSession({
  64. dialog,
  65. sdk,
  66. sync,
  67. project,
  68. toast,
  69. sourceWorkspaceID: session.workspaceID,
  70. workspaceID,
  71. sessionID: session.id,
  72. copyChanges: false,
  73. done: list,
  74. })
  75. }
  76. dialog.replace(() => (
  77. <DialogSessionDeleteFailed
  78. session={session.title}
  79. workspace={workspace?.name ?? session.workspaceID!}
  80. onDone={list}
  81. onDelete={async () => {
  82. const current = currentSessionID()
  83. const info = current ? sync.data.session.find((item) => item.id === current) : undefined
  84. const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
  85. if (result.error) {
  86. toast.show({
  87. variant: "error",
  88. title: "Failed to delete workspace",
  89. message: errorMessage(result.error),
  90. })
  91. return false
  92. }
  93. await project.workspace.sync()
  94. await sync.session.refresh()
  95. if (search()) await refetch()
  96. if (info?.workspaceID === session.workspaceID) {
  97. route.navigate({ type: "home" })
  98. }
  99. return true
  100. }}
  101. onRestore={() => {
  102. void openWorkspaceSelect({
  103. dialog,
  104. sdk,
  105. sync,
  106. project,
  107. toast,
  108. onSelect: (selection) => {
  109. void warp(selection)
  110. },
  111. })
  112. return false
  113. }}
  114. />
  115. ))
  116. }
  117. const options = createMemo(() => {
  118. const today = new Date().toDateString()
  119. return sessions()
  120. .filter((x) => x.parentID === undefined)
  121. .toSorted((a, b) => {
  122. const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0)
  123. if (updatedDay !== 0) return updatedDay
  124. return b.time.created - a.time.created
  125. })
  126. .map((x) => {
  127. const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
  128. let footer: JSX.Element | string = ""
  129. if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
  130. if (x.workspaceID) {
  131. footer = workspace ? (
  132. <WorkspaceLabel
  133. type={workspace.type}
  134. name={workspace.name}
  135. status={project.workspace.status(x.workspaceID) ?? "error"}
  136. />
  137. ) : (
  138. <WorkspaceLabel type="unknown" name={x.workspaceID} status="error" />
  139. )
  140. }
  141. } else {
  142. footer = Locale.time(x.time.updated)
  143. }
  144. const date = new Date(x.time.updated)
  145. let category = date.toDateString()
  146. if (category === today) {
  147. category = "Today"
  148. }
  149. const isDeleting = toDelete() === x.id
  150. const status = sync.data.session_status?.[x.id]
  151. const isWorking = status?.type === "busy" || status?.type === "retry"
  152. return {
  153. title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
  154. bg: isDeleting ? theme.error : undefined,
  155. value: x.id,
  156. category,
  157. footer,
  158. gutter: isWorking ? () => <Spinner /> : undefined,
  159. }
  160. })
  161. })
  162. onMount(() => {
  163. dialog.setSize("large")
  164. })
  165. return (
  166. <DialogSelect
  167. title="Sessions"
  168. options={options()}
  169. skipFilter={true}
  170. current={currentSessionID()}
  171. onFilter={setSearch}
  172. onMove={() => {
  173. setToDelete(undefined)
  174. }}
  175. onSelect={(option) => {
  176. route.navigate({
  177. type: "session",
  178. sessionID: option.value,
  179. })
  180. dialog.clear()
  181. }}
  182. actions={[
  183. {
  184. command: "dialog.action.delete",
  185. title: "delete",
  186. onTrigger: async (option) => {
  187. if (toDelete() === option.value) {
  188. const session = sessions().find((item) => item.id === option.value)
  189. const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
  190. try {
  191. const result = await sdk.client.session.delete({
  192. sessionID: option.value,
  193. })
  194. if (result.error) {
  195. if (session?.workspaceID) {
  196. recover(session)
  197. } else {
  198. toast.show({
  199. variant: "error",
  200. title: "Failed to delete session",
  201. message: errorMessage(result.error),
  202. })
  203. }
  204. setToDelete(undefined)
  205. return
  206. }
  207. } catch (err) {
  208. if (session?.workspaceID) {
  209. recover(session)
  210. } else {
  211. toast.show({
  212. variant: "error",
  213. title: "Failed to delete session",
  214. message: errorMessage(err),
  215. })
  216. }
  217. setToDelete(undefined)
  218. return
  219. }
  220. if (status && status !== "connected") {
  221. await sync.session.refresh()
  222. }
  223. if (search()) await refetch()
  224. setToDelete(undefined)
  225. return
  226. }
  227. setToDelete(option.value)
  228. },
  229. },
  230. {
  231. command: "dialog.action.rename",
  232. title: "rename",
  233. onTrigger: async (option) => {
  234. dialog.replace(() => <DialogSessionRename session={option.value} />)
  235. },
  236. },
  237. ]}
  238. />
  239. )
  240. }