session.tsx 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353
  1. import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
  2. import { createMediaQuery } from "@solid-primitives/media"
  3. import { createResizeObserver } from "@solid-primitives/resize-observer"
  4. import { Dynamic } from "solid-js/web"
  5. import { useLocal } from "@/context/local"
  6. import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
  7. import { createStore } from "solid-js/store"
  8. import { PromptInput } from "@/components/prompt-input"
  9. import { SessionContextUsage } from "@/components/session-context-usage"
  10. import { IconButton } from "@opencode-ai/ui/icon-button"
  11. import { Icon } from "@opencode-ai/ui/icon"
  12. import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
  13. import { DiffChanges } from "@opencode-ai/ui/diff-changes"
  14. import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
  15. import { Tabs } from "@opencode-ai/ui/tabs"
  16. import { useCodeComponent } from "@opencode-ai/ui/context/code"
  17. import { SessionTurn } from "@opencode-ai/ui/session-turn"
  18. import { createAutoScroll } from "@opencode-ai/ui/hooks"
  19. import { SessionReview } from "@opencode-ai/ui/session-review"
  20. import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
  21. import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
  22. import type { DragEvent } from "@thisbeyond/solid-dnd"
  23. import { useSync } from "@/context/sync"
  24. import { useTerminal, type LocalPTY } from "@/context/terminal"
  25. import { useLayout } from "@/context/layout"
  26. import { Terminal } from "@/components/terminal"
  27. import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
  28. import { useDialog } from "@opencode-ai/ui/context/dialog"
  29. import { DialogSelectFile } from "@/components/dialog-select-file"
  30. import { DialogSelectModel } from "@/components/dialog-select-model"
  31. import { DialogSelectMcp } from "@/components/dialog-select-mcp"
  32. import { useCommand } from "@/context/command"
  33. import { useNavigate, useParams } from "@solidjs/router"
  34. import { UserMessage } from "@opencode-ai/sdk/v2"
  35. import type { FileDiff } from "@opencode-ai/sdk/v2/client"
  36. import { useSDK } from "@/context/sdk"
  37. import { usePrompt } from "@/context/prompt"
  38. import { extractPromptFromParts } from "@/utils/prompt"
  39. import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
  40. import { usePermission } from "@/context/permission"
  41. import { showToast } from "@opencode-ai/ui/toast"
  42. import {
  43. SessionHeader,
  44. SessionContextTab,
  45. SortableTab,
  46. FileVisual,
  47. SortableTerminalTab,
  48. NewSessionView,
  49. } from "@/components/session"
  50. import { usePlatform } from "@/context/platform"
  51. import { same } from "@/utils/same"
  52. type DiffStyle = "unified" | "split"
  53. interface SessionReviewTabProps {
  54. diffs: () => FileDiff[]
  55. view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
  56. diffStyle: DiffStyle
  57. onDiffStyleChange?: (style: DiffStyle) => void
  58. classes?: {
  59. root?: string
  60. header?: string
  61. container?: string
  62. }
  63. }
  64. function SessionReviewTab(props: SessionReviewTabProps) {
  65. let scroll: HTMLDivElement | undefined
  66. let frame: number | undefined
  67. let pending: { x: number; y: number } | undefined
  68. const restoreScroll = (retries = 0) => {
  69. const el = scroll
  70. if (!el) return
  71. const s = props.view().scroll("review")
  72. if (!s) return
  73. // Wait for content to be scrollable - content may not have rendered yet
  74. if (el.scrollHeight <= el.clientHeight && retries < 10) {
  75. requestAnimationFrame(() => restoreScroll(retries + 1))
  76. return
  77. }
  78. if (el.scrollTop !== s.y) el.scrollTop = s.y
  79. if (el.scrollLeft !== s.x) el.scrollLeft = s.x
  80. }
  81. const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
  82. pending = {
  83. x: event.currentTarget.scrollLeft,
  84. y: event.currentTarget.scrollTop,
  85. }
  86. if (frame !== undefined) return
  87. frame = requestAnimationFrame(() => {
  88. frame = undefined
  89. const next = pending
  90. pending = undefined
  91. if (!next) return
  92. props.view().setScroll("review", next)
  93. })
  94. }
  95. createEffect(
  96. on(
  97. () => props.diffs().length,
  98. () => {
  99. requestAnimationFrame(restoreScroll)
  100. },
  101. { defer: true },
  102. ),
  103. )
  104. onCleanup(() => {
  105. if (frame === undefined) return
  106. cancelAnimationFrame(frame)
  107. })
  108. return (
  109. <SessionReview
  110. scrollRef={(el) => {
  111. scroll = el
  112. restoreScroll()
  113. }}
  114. onScroll={handleScroll}
  115. open={props.view().review.open()}
  116. onOpenChange={props.view().review.setOpen}
  117. classes={{
  118. root: props.classes?.root ?? "pb-40",
  119. header: props.classes?.header ?? "px-6",
  120. container: props.classes?.container ?? "px-6",
  121. }}
  122. diffs={props.diffs()}
  123. diffStyle={props.diffStyle}
  124. onDiffStyleChange={props.onDiffStyleChange}
  125. />
  126. )
  127. }
  128. export default function Page() {
  129. const layout = useLayout()
  130. const local = useLocal()
  131. const file = useFile()
  132. const sync = useSync()
  133. const terminal = useTerminal()
  134. const dialog = useDialog()
  135. const codeComponent = useCodeComponent()
  136. const command = useCommand()
  137. const platform = usePlatform()
  138. const params = useParams()
  139. const navigate = useNavigate()
  140. const sdk = useSDK()
  141. const prompt = usePrompt()
  142. const permission = usePermission()
  143. const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
  144. const tabs = createMemo(() => layout.tabs(sessionKey()))
  145. const view = createMemo(() => layout.view(sessionKey()))
  146. const isDesktop = createMediaQuery("(min-width: 768px)")
  147. function normalizeTab(tab: string) {
  148. if (!tab.startsWith("file://")) return tab
  149. return file.tab(tab)
  150. }
  151. function normalizeTabs(list: string[]) {
  152. const seen = new Set<string>()
  153. const next: string[] = []
  154. for (const item of list) {
  155. const value = normalizeTab(item)
  156. if (seen.has(value)) continue
  157. seen.add(value)
  158. next.push(value)
  159. }
  160. return next
  161. }
  162. const openTab = (value: string) => {
  163. const next = normalizeTab(value)
  164. tabs().open(next)
  165. const path = file.pathFromTab(next)
  166. if (path) file.load(path)
  167. }
  168. createEffect(() => {
  169. const active = tabs().active()
  170. if (!active) return
  171. const path = file.pathFromTab(active)
  172. if (path) file.load(path)
  173. })
  174. createEffect(() => {
  175. const current = tabs().all()
  176. if (current.length === 0) return
  177. const next = normalizeTabs(current)
  178. if (same(current, next)) return
  179. tabs().setAll(next)
  180. const active = tabs().active()
  181. if (!active) return
  182. if (!active.startsWith("file://")) return
  183. const normalized = normalizeTab(active)
  184. if (active === normalized) return
  185. tabs().setActive(normalized)
  186. })
  187. const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
  188. const revertMessageID = createMemo(() => info()?.revert?.messageID)
  189. const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
  190. const messagesReady = createMemo(() => {
  191. const id = params.id
  192. if (!id) return true
  193. return sync.data.message[id] !== undefined
  194. })
  195. const emptyUserMessages: UserMessage[] = []
  196. const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
  197. const visibleUserMessages = createMemo(() => {
  198. const revert = revertMessageID()
  199. if (!revert) return userMessages()
  200. return userMessages().filter((m) => m.id < revert)
  201. }, emptyUserMessages)
  202. const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
  203. createEffect(
  204. on(
  205. () => lastUserMessage()?.id,
  206. () => {
  207. const msg = lastUserMessage()
  208. if (!msg) return
  209. if (msg.agent) local.agent.set(msg.agent)
  210. if (msg.model) local.model.set(msg.model)
  211. },
  212. ),
  213. )
  214. const [store, setStore] = createStore({
  215. activeDraggable: undefined as string | undefined,
  216. activeTerminalDraggable: undefined as string | undefined,
  217. expanded: {} as Record<string, boolean>,
  218. messageId: undefined as string | undefined,
  219. mobileTab: "session" as "session" | "review",
  220. newSessionWorktree: "main",
  221. promptHeight: 0,
  222. })
  223. const newSessionWorktree = createMemo(() => {
  224. if (store.newSessionWorktree === "create") return "create"
  225. const project = sync.project
  226. if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
  227. return "main"
  228. })
  229. const activeMessage = createMemo(() => {
  230. if (!store.messageId) return lastUserMessage()
  231. const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
  232. return found ?? lastUserMessage()
  233. })
  234. const setActiveMessage = (message: UserMessage | undefined) => {
  235. setStore("messageId", message?.id)
  236. }
  237. function navigateMessageByOffset(offset: number) {
  238. const msgs = visibleUserMessages()
  239. if (msgs.length === 0) return
  240. const current = activeMessage()
  241. const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
  242. let targetIndex: number
  243. if (currentIndex === -1) {
  244. targetIndex = offset > 0 ? 0 : msgs.length - 1
  245. } else {
  246. targetIndex = currentIndex + offset
  247. }
  248. if (targetIndex < 0 || targetIndex >= msgs.length) return
  249. scrollToMessage(msgs[targetIndex], "auto")
  250. }
  251. const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
  252. const idle = { type: "idle" as const }
  253. let inputRef!: HTMLDivElement
  254. let promptDock: HTMLDivElement | undefined
  255. let scroller: HTMLDivElement | undefined
  256. createEffect(() => {
  257. if (!params.id) return
  258. sync.session.sync(params.id)
  259. })
  260. createEffect(() => {
  261. if (layout.terminal.opened()) {
  262. if (terminal.all().length === 0) {
  263. terminal.new()
  264. }
  265. }
  266. })
  267. createEffect(
  268. on(
  269. () => visibleUserMessages().at(-1)?.id,
  270. (lastId, prevLastId) => {
  271. if (lastId && prevLastId && lastId > prevLastId) {
  272. setStore("messageId", undefined)
  273. }
  274. },
  275. { defer: true },
  276. ),
  277. )
  278. const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
  279. createEffect(
  280. on(
  281. () => params.id,
  282. () => {
  283. setStore("messageId", undefined)
  284. setStore("expanded", {})
  285. },
  286. { defer: true },
  287. ),
  288. )
  289. createEffect(() => {
  290. const id = lastUserMessage()?.id
  291. if (!id) return
  292. setStore("expanded", id, status().type !== "idle")
  293. })
  294. command.register(() => [
  295. {
  296. id: "session.new",
  297. title: "New session",
  298. description: "Create a new session",
  299. category: "Session",
  300. keybind: "mod+shift+s",
  301. slash: "new",
  302. onSelect: () => navigate(`/${params.dir}/session`),
  303. },
  304. {
  305. id: "file.open",
  306. title: "Open file",
  307. description: "Search and open a file",
  308. category: "File",
  309. keybind: "mod+p",
  310. slash: "open",
  311. onSelect: () => dialog.show(() => <DialogSelectFile />),
  312. },
  313. {
  314. id: "terminal.toggle",
  315. title: "Toggle terminal",
  316. description: "Show or hide the terminal",
  317. category: "View",
  318. keybind: "ctrl+`",
  319. slash: "terminal",
  320. onSelect: () => layout.terminal.toggle(),
  321. },
  322. {
  323. id: "review.toggle",
  324. title: "Toggle review",
  325. description: "Show or hide the review panel",
  326. category: "View",
  327. keybind: "mod+shift+r",
  328. onSelect: () => layout.review.toggle(),
  329. },
  330. {
  331. id: "terminal.new",
  332. title: "New terminal",
  333. description: "Create a new terminal tab",
  334. category: "Terminal",
  335. keybind: "ctrl+shift+`",
  336. onSelect: () => terminal.new(),
  337. },
  338. {
  339. id: "steps.toggle",
  340. title: "Toggle steps",
  341. description: "Show or hide steps for the current message",
  342. category: "View",
  343. keybind: "mod+e",
  344. slash: "steps",
  345. disabled: !params.id,
  346. onSelect: () => {
  347. const msg = activeMessage()
  348. if (!msg) return
  349. setStore("expanded", msg.id, (open: boolean | undefined) => !open)
  350. },
  351. },
  352. {
  353. id: "message.previous",
  354. title: "Previous message",
  355. description: "Go to the previous user message",
  356. category: "Session",
  357. keybind: "mod+arrowup",
  358. disabled: !params.id,
  359. onSelect: () => navigateMessageByOffset(-1),
  360. },
  361. {
  362. id: "message.next",
  363. title: "Next message",
  364. description: "Go to the next user message",
  365. category: "Session",
  366. keybind: "mod+arrowdown",
  367. disabled: !params.id,
  368. onSelect: () => navigateMessageByOffset(1),
  369. },
  370. {
  371. id: "model.choose",
  372. title: "Choose model",
  373. description: "Select a different model",
  374. category: "Model",
  375. keybind: "mod+'",
  376. slash: "model",
  377. onSelect: () => dialog.show(() => <DialogSelectModel />),
  378. },
  379. {
  380. id: "mcp.toggle",
  381. title: "Toggle MCPs",
  382. description: "Toggle MCPs",
  383. category: "MCP",
  384. keybind: "mod+;",
  385. slash: "mcp",
  386. onSelect: () => dialog.show(() => <DialogSelectMcp />),
  387. },
  388. {
  389. id: "agent.cycle",
  390. title: "Cycle agent",
  391. description: "Switch to the next agent",
  392. category: "Agent",
  393. keybind: "mod+.",
  394. slash: "agent",
  395. onSelect: () => local.agent.move(1),
  396. },
  397. {
  398. id: "agent.cycle.reverse",
  399. title: "Cycle agent backwards",
  400. description: "Switch to the previous agent",
  401. category: "Agent",
  402. keybind: "shift+mod+.",
  403. onSelect: () => local.agent.move(-1),
  404. },
  405. {
  406. id: "model.variant.cycle",
  407. title: "Cycle thinking effort",
  408. description: "Switch to the next effort level",
  409. category: "Model",
  410. keybind: "shift+mod+t",
  411. onSelect: () => {
  412. local.model.variant.cycle()
  413. showToast({
  414. title: "Thinking effort changed",
  415. description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
  416. })
  417. },
  418. },
  419. {
  420. id: "permissions.autoaccept",
  421. title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
  422. category: "Permissions",
  423. keybind: "mod+shift+a",
  424. disabled: !params.id || !permission.permissionsEnabled(),
  425. onSelect: () => {
  426. const sessionID = params.id
  427. if (!sessionID) return
  428. permission.toggleAutoAccept(sessionID, sdk.directory)
  429. showToast({
  430. title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
  431. description: permission.isAutoAccepting(sessionID)
  432. ? "Edit and write permissions will be automatically approved"
  433. : "Edit and write permissions will require approval",
  434. })
  435. },
  436. },
  437. {
  438. id: "session.undo",
  439. title: "Undo",
  440. description: "Undo the last message",
  441. category: "Session",
  442. slash: "undo",
  443. disabled: !params.id || visibleUserMessages().length === 0,
  444. onSelect: async () => {
  445. const sessionID = params.id
  446. if (!sessionID) return
  447. if (status()?.type !== "idle") {
  448. await sdk.client.session.abort({ sessionID }).catch(() => {})
  449. }
  450. const revert = info()?.revert?.messageID
  451. // Find the last user message that's not already reverted
  452. const message = userMessages().findLast((x) => !revert || x.id < revert)
  453. if (!message) return
  454. await sdk.client.session.revert({ sessionID, messageID: message.id })
  455. // Restore the prompt from the reverted message
  456. const parts = sync.data.part[message.id]
  457. if (parts) {
  458. const restored = extractPromptFromParts(parts)
  459. prompt.set(restored)
  460. }
  461. // Navigate to the message before the reverted one (which will be the new last visible message)
  462. const priorMessage = userMessages().findLast((x) => x.id < message.id)
  463. setActiveMessage(priorMessage)
  464. },
  465. },
  466. {
  467. id: "session.redo",
  468. title: "Redo",
  469. description: "Redo the last undone message",
  470. category: "Session",
  471. slash: "redo",
  472. disabled: !params.id || !info()?.revert?.messageID,
  473. onSelect: async () => {
  474. const sessionID = params.id
  475. if (!sessionID) return
  476. const revertMessageID = info()?.revert?.messageID
  477. if (!revertMessageID) return
  478. const nextMessage = userMessages().find((x) => x.id > revertMessageID)
  479. if (!nextMessage) {
  480. // Full unrevert - restore all messages and navigate to last
  481. await sdk.client.session.unrevert({ sessionID })
  482. prompt.reset()
  483. // Navigate to the last message (the one that was at the revert point)
  484. const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
  485. setActiveMessage(lastMsg)
  486. return
  487. }
  488. // Partial redo - move forward to next message
  489. await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
  490. // Navigate to the message before the new revert point
  491. const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
  492. setActiveMessage(priorMsg)
  493. },
  494. },
  495. {
  496. id: "session.compact",
  497. title: "Compact session",
  498. description: "Summarize the session to reduce context size",
  499. category: "Session",
  500. slash: "compact",
  501. disabled: !params.id || visibleUserMessages().length === 0,
  502. onSelect: async () => {
  503. const sessionID = params.id
  504. if (!sessionID) return
  505. const model = local.model.current()
  506. if (!model) {
  507. showToast({
  508. title: "No model selected",
  509. description: "Connect a provider to summarize this session",
  510. })
  511. return
  512. }
  513. await sdk.client.session.summarize({
  514. sessionID,
  515. modelID: model.id,
  516. providerID: model.provider.id,
  517. })
  518. },
  519. },
  520. ])
  521. const handleKeyDown = (event: KeyboardEvent) => {
  522. const activeElement = document.activeElement as HTMLElement | undefined
  523. if (activeElement) {
  524. const isProtected = activeElement.closest("[data-prevent-autofocus]")
  525. const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
  526. if (isProtected || isInput) return
  527. }
  528. if (dialog.active) return
  529. if (activeElement === inputRef) {
  530. if (event.key === "Escape") inputRef?.blur()
  531. return
  532. }
  533. if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
  534. inputRef?.focus()
  535. }
  536. }
  537. const handleDragStart = (event: unknown) => {
  538. const id = getDraggableId(event)
  539. if (!id) return
  540. setStore("activeDraggable", id)
  541. }
  542. const handleDragOver = (event: DragEvent) => {
  543. const { draggable, droppable } = event
  544. if (draggable && droppable) {
  545. const currentTabs = tabs().all()
  546. const fromIndex = currentTabs?.indexOf(draggable.id.toString())
  547. const toIndex = currentTabs?.indexOf(droppable.id.toString())
  548. if (fromIndex !== toIndex && toIndex !== undefined) {
  549. tabs().move(draggable.id.toString(), toIndex)
  550. }
  551. }
  552. }
  553. const handleDragEnd = () => {
  554. setStore("activeDraggable", undefined)
  555. }
  556. const handleTerminalDragStart = (event: unknown) => {
  557. const id = getDraggableId(event)
  558. if (!id) return
  559. setStore("activeTerminalDraggable", id)
  560. }
  561. const handleTerminalDragOver = (event: DragEvent) => {
  562. const { draggable, droppable } = event
  563. if (draggable && droppable) {
  564. const terminals = terminal.all()
  565. const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
  566. const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
  567. if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
  568. terminal.move(draggable.id.toString(), toIndex)
  569. }
  570. }
  571. }
  572. const handleTerminalDragEnd = () => {
  573. setStore("activeTerminalDraggable", undefined)
  574. }
  575. const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
  576. const openedTabs = createMemo(() =>
  577. tabs()
  578. .all()
  579. .filter((tab) => tab !== "context"),
  580. )
  581. const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
  582. const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
  583. const showTabs = createMemo(
  584. () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
  585. )
  586. const activeTab = createMemo(() => {
  587. const active = tabs().active()
  588. if (active) return active
  589. if (reviewTab()) return "review"
  590. const first = openedTabs()[0]
  591. if (first) return first
  592. if (contextOpen()) return "context"
  593. return "review"
  594. })
  595. createEffect(() => {
  596. if (!layout.ready()) return
  597. if (tabs().active()) return
  598. if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
  599. tabs().setActive(activeTab())
  600. })
  601. const isWorking = createMemo(() => status().type !== "idle")
  602. const autoScroll = createAutoScroll({
  603. working: isWorking,
  604. })
  605. let scrollSpyFrame: number | undefined
  606. let scrollSpyTarget: HTMLDivElement | undefined
  607. const anchor = (id: string) => `message-${id}`
  608. const setScrollRef = (el: HTMLDivElement | undefined) => {
  609. scroller = el
  610. autoScroll.scrollRef(el)
  611. }
  612. createResizeObserver(
  613. () => promptDock,
  614. ({ height }) => {
  615. const next = Math.ceil(height)
  616. if (next === store.promptHeight) return
  617. const el = scroller
  618. const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
  619. setStore("promptHeight", next)
  620. if (stick && el) {
  621. requestAnimationFrame(() => {
  622. el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
  623. })
  624. }
  625. },
  626. )
  627. const updateHash = (id: string) => {
  628. window.history.replaceState(null, "", `#${anchor(id)}`)
  629. }
  630. const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
  631. setActiveMessage(message)
  632. const el = document.getElementById(anchor(message.id))
  633. if (el) el.scrollIntoView({ behavior, block: "start" })
  634. updateHash(message.id)
  635. }
  636. const getActiveMessageId = (container: HTMLDivElement) => {
  637. const cutoff = container.scrollTop + 100
  638. const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
  639. let id: string | undefined
  640. for (const node of nodes) {
  641. const next = node.dataset.messageId
  642. if (!next) continue
  643. if (node.offsetTop > cutoff) break
  644. id = next
  645. }
  646. return id
  647. }
  648. const scheduleScrollSpy = (container: HTMLDivElement) => {
  649. scrollSpyTarget = container
  650. if (scrollSpyFrame !== undefined) return
  651. scrollSpyFrame = requestAnimationFrame(() => {
  652. scrollSpyFrame = undefined
  653. const target = scrollSpyTarget
  654. scrollSpyTarget = undefined
  655. if (!target) return
  656. const id = getActiveMessageId(target)
  657. if (!id) return
  658. if (id === store.messageId) return
  659. setStore("messageId", id)
  660. })
  661. }
  662. createEffect(() => {
  663. const sessionID = params.id
  664. const ready = messagesReady()
  665. if (!sessionID || !ready) return
  666. requestAnimationFrame(() => {
  667. const id = window.location.hash.slice(1)
  668. const hashTarget = id ? document.getElementById(id) : undefined
  669. if (hashTarget) {
  670. hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
  671. return
  672. }
  673. autoScroll.forceScrollToBottom()
  674. })
  675. })
  676. createEffect(() => {
  677. document.addEventListener("keydown", handleKeyDown)
  678. })
  679. onCleanup(() => {
  680. document.removeEventListener("keydown", handleKeyDown)
  681. if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
  682. })
  683. return (
  684. <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
  685. <SessionHeader />
  686. <div class="flex-1 min-h-0 flex flex-col md:flex-row">
  687. {/* Mobile tab bar - only shown on mobile when there are diffs */}
  688. <Show when={!isDesktop() && diffs().length > 0}>
  689. <Tabs class="h-auto">
  690. <Tabs.List>
  691. <Tabs.Trigger
  692. value="session"
  693. class="w-1/2"
  694. classes={{ button: "w-full" }}
  695. onClick={() => setStore("mobileTab", "session")}
  696. >
  697. Session
  698. </Tabs.Trigger>
  699. <Tabs.Trigger
  700. value="review"
  701. class="w-1/2 !border-r-0"
  702. classes={{ button: "w-full" }}
  703. onClick={() => setStore("mobileTab", "review")}
  704. >
  705. {diffs().length} Files Changed
  706. </Tabs.Trigger>
  707. </Tabs.List>
  708. </Tabs>
  709. </Show>
  710. {/* Session panel */}
  711. <div
  712. classList={{
  713. "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
  714. "flex-1 md:flex-none py-6 md:py-3": true,
  715. }}
  716. style={{
  717. width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
  718. "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
  719. }}
  720. >
  721. <div class="flex-1 min-h-0 overflow-hidden">
  722. <Switch>
  723. <Match when={params.id}>
  724. <Show when={activeMessage()}>
  725. <Show
  726. when={!mobileReview()}
  727. fallback={
  728. <div class="relative h-full overflow-hidden">
  729. <SessionReviewTab
  730. diffs={diffs}
  731. view={view}
  732. diffStyle="unified"
  733. classes={{
  734. root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
  735. header: "px-4",
  736. container: "px-4",
  737. }}
  738. />
  739. </div>
  740. }
  741. >
  742. <div class="relative w-full h-full min-w-0">
  743. <Show when={isDesktop()}>
  744. <div class="absolute inset-0 pointer-events-none z-10">
  745. <SessionMessageRail
  746. messages={visibleUserMessages()}
  747. current={activeMessage()}
  748. onMessageSelect={scrollToMessage}
  749. wide={!showTabs()}
  750. class="pointer-events-auto"
  751. />
  752. </div>
  753. </Show>
  754. <div
  755. ref={setScrollRef}
  756. onScroll={(e) => {
  757. autoScroll.handleScroll()
  758. if (isDesktop()) scheduleScrollSpy(e.currentTarget)
  759. }}
  760. onClick={autoScroll.handleInteraction}
  761. class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
  762. >
  763. <div
  764. ref={autoScroll.contentRef}
  765. class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
  766. classList={{
  767. "mt-0.5": !showTabs(),
  768. "mt-0": showTabs(),
  769. }}
  770. >
  771. <For each={visibleUserMessages()}>
  772. {(message) => (
  773. <div
  774. id={anchor(message.id)}
  775. data-message-id={message.id}
  776. classList={{
  777. "min-w-0 w-full max-w-full": true,
  778. "last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-32px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-32px)]":
  779. platform.platform !== "desktop",
  780. "last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-32px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-32px)]":
  781. platform.platform === "desktop",
  782. }}
  783. >
  784. <SessionTurn
  785. sessionID={params.id!}
  786. messageID={message.id}
  787. lastUserMessageID={lastUserMessage()?.id}
  788. stepsExpanded={store.expanded[message.id] ?? false}
  789. onStepsExpandedToggle={() =>
  790. setStore("expanded", message.id, (open: boolean | undefined) => !open)
  791. }
  792. classes={{
  793. root: "min-w-0 w-full relative",
  794. content:
  795. "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
  796. container:
  797. "px-4 md:px-6 " +
  798. (!showTabs()
  799. ? "md:max-w-200 md:mx-auto"
  800. : visibleUserMessages().length > 1
  801. ? "md:pr-6 md:pl-18"
  802. : ""),
  803. }}
  804. />
  805. </div>
  806. )}
  807. </For>
  808. </div>
  809. </div>
  810. </div>
  811. </Show>
  812. </Show>
  813. </Match>
  814. <Match when={true}>
  815. <NewSessionView
  816. worktree={newSessionWorktree()}
  817. onWorktreeChange={(value) => {
  818. if (value === "create") {
  819. setStore("newSessionWorktree", value)
  820. return
  821. }
  822. setStore("newSessionWorktree", "main")
  823. const target = value === "main" ? sync.project?.worktree : value
  824. if (!target) return
  825. if (target === sync.data.path.directory) return
  826. layout.projects.open(target)
  827. navigate(`/${base64Encode(target)}/session`)
  828. }}
  829. />
  830. </Match>
  831. </Switch>
  832. </div>
  833. {/* Prompt input */}
  834. <div
  835. ref={(el) => (promptDock = el)}
  836. class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
  837. >
  838. <div
  839. classList={{
  840. "w-full md:px-6 pointer-events-auto": true,
  841. "md:max-w-200": !showTabs(),
  842. }}
  843. >
  844. <PromptInput
  845. ref={(el) => {
  846. inputRef = el
  847. }}
  848. newSessionWorktree={newSessionWorktree()}
  849. onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
  850. />
  851. </div>
  852. </div>
  853. <Show when={isDesktop() && showTabs()}>
  854. <ResizeHandle
  855. direction="horizontal"
  856. size={layout.session.width()}
  857. min={450}
  858. max={window.innerWidth * 0.45}
  859. onResize={layout.session.resize}
  860. />
  861. </Show>
  862. </div>
  863. {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
  864. <Show when={isDesktop() && showTabs()}>
  865. <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
  866. <DragDropProvider
  867. onDragStart={handleDragStart}
  868. onDragEnd={handleDragEnd}
  869. onDragOver={handleDragOver}
  870. collisionDetector={closestCenter}
  871. >
  872. <DragDropSensors />
  873. <ConstrainDragYAxis />
  874. <Tabs value={activeTab()} onChange={openTab}>
  875. <div class="sticky top-0 shrink-0 flex">
  876. <Tabs.List>
  877. <Show when={reviewTab()}>
  878. <Tabs.Trigger value="review">
  879. <div class="flex items-center gap-3">
  880. <Show when={diffs()}>
  881. <DiffChanges changes={diffs()} variant="bars" />
  882. </Show>
  883. <div class="flex items-center gap-1.5">
  884. <div>Review</div>
  885. <Show when={info()?.summary?.files}>
  886. <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
  887. {info()?.summary?.files ?? 0}
  888. </div>
  889. </Show>
  890. </div>
  891. </div>
  892. </Tabs.Trigger>
  893. </Show>
  894. <Show when={contextOpen()}>
  895. <Tabs.Trigger
  896. value="context"
  897. closeButton={
  898. <Tooltip value="Close tab" placement="bottom">
  899. <IconButton icon="close" variant="ghost" onClick={() => tabs().close("context")} />
  900. </Tooltip>
  901. }
  902. hideCloseButton
  903. >
  904. <div class="flex items-center gap-2">
  905. <SessionContextUsage variant="indicator" />
  906. <div>Context</div>
  907. </div>
  908. </Tabs.Trigger>
  909. </Show>
  910. <SortableProvider ids={openedTabs()}>
  911. <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
  912. </SortableProvider>
  913. <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
  914. <TooltipKeybind
  915. title="Open file"
  916. keybind={command.keybind("file.open")}
  917. class="flex items-center"
  918. >
  919. <IconButton
  920. icon="plus-small"
  921. variant="ghost"
  922. iconSize="large"
  923. onClick={() => dialog.show(() => <DialogSelectFile />)}
  924. />
  925. </TooltipKeybind>
  926. </div>
  927. </Tabs.List>
  928. </div>
  929. <Show when={reviewTab()}>
  930. <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
  931. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  932. <SessionReviewTab
  933. diffs={diffs}
  934. view={view}
  935. diffStyle={layout.review.diffStyle()}
  936. onDiffStyleChange={layout.review.setDiffStyle}
  937. />
  938. </div>
  939. </Tabs.Content>
  940. </Show>
  941. <Show when={contextOpen()}>
  942. <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
  943. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  944. <SessionContextTab
  945. messages={messages}
  946. visibleUserMessages={visibleUserMessages}
  947. view={view}
  948. info={info}
  949. />
  950. </div>
  951. </Tabs.Content>
  952. </Show>
  953. <For each={openedTabs()}>
  954. {(tab) => {
  955. let scroll: HTMLDivElement | undefined
  956. let scrollFrame: number | undefined
  957. let pending: { x: number; y: number } | undefined
  958. const path = createMemo(() => file.pathFromTab(tab))
  959. const state = createMemo(() => {
  960. const p = path()
  961. if (!p) return
  962. return file.get(p)
  963. })
  964. const contents = createMemo(() => state()?.content?.content ?? "")
  965. const cacheKey = createMemo(() => checksum(contents()))
  966. const isImage = createMemo(() => {
  967. const c = state()?.content
  968. return (
  969. c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
  970. )
  971. })
  972. const isSvg = createMemo(() => {
  973. const c = state()?.content
  974. return c?.mimeType === "image/svg+xml"
  975. })
  976. const svgContent = createMemo(() => {
  977. if (!isSvg()) return
  978. const c = state()?.content
  979. if (!c) return
  980. if (c.encoding === "base64") return base64Decode(c.content)
  981. return c.content
  982. })
  983. const svgPreviewUrl = createMemo(() => {
  984. if (!isSvg()) return
  985. const c = state()?.content
  986. if (!c) return
  987. if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
  988. return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
  989. })
  990. const imageDataUrl = createMemo(() => {
  991. if (!isImage()) return
  992. const c = state()?.content
  993. return `data:${c?.mimeType};base64,${c?.content}`
  994. })
  995. const selectedLines = createMemo(() => {
  996. const p = path()
  997. if (!p) return null
  998. return file.selectedLines(p) ?? null
  999. })
  1000. const selection = createMemo(() => {
  1001. const range = selectedLines()
  1002. if (!range) return
  1003. return selectionFromLines(range)
  1004. })
  1005. const selectionLabel = createMemo(() => {
  1006. const sel = selection()
  1007. if (!sel) return
  1008. if (sel.startLine === sel.endLine) return `L${sel.startLine}`
  1009. return `L${sel.startLine}-${sel.endLine}`
  1010. })
  1011. const restoreScroll = (retries = 0) => {
  1012. const el = scroll
  1013. if (!el) return
  1014. const s = view()?.scroll(tab)
  1015. if (!s) return
  1016. // Wait for content to be scrollable - content may not have rendered yet
  1017. if (el.scrollHeight <= el.clientHeight && retries < 10) {
  1018. requestAnimationFrame(() => restoreScroll(retries + 1))
  1019. return
  1020. }
  1021. if (el.scrollTop !== s.y) el.scrollTop = s.y
  1022. if (el.scrollLeft !== s.x) el.scrollLeft = s.x
  1023. }
  1024. const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
  1025. pending = {
  1026. x: event.currentTarget.scrollLeft,
  1027. y: event.currentTarget.scrollTop,
  1028. }
  1029. if (scrollFrame !== undefined) return
  1030. scrollFrame = requestAnimationFrame(() => {
  1031. scrollFrame = undefined
  1032. const next = pending
  1033. pending = undefined
  1034. if (!next) return
  1035. view().setScroll(tab, next)
  1036. })
  1037. }
  1038. createEffect(
  1039. on(
  1040. () => state()?.loaded,
  1041. (loaded) => {
  1042. if (!loaded) return
  1043. requestAnimationFrame(restoreScroll)
  1044. },
  1045. { defer: true },
  1046. ),
  1047. )
  1048. createEffect(
  1049. on(
  1050. () => file.ready(),
  1051. (ready) => {
  1052. if (!ready) return
  1053. requestAnimationFrame(restoreScroll)
  1054. },
  1055. { defer: true },
  1056. ),
  1057. )
  1058. createEffect(
  1059. on(
  1060. () => tabs().active() === tab,
  1061. (active) => {
  1062. if (!active) return
  1063. if (!state()?.loaded) return
  1064. requestAnimationFrame(restoreScroll)
  1065. },
  1066. ),
  1067. )
  1068. onCleanup(() => {
  1069. if (scrollFrame === undefined) return
  1070. cancelAnimationFrame(scrollFrame)
  1071. })
  1072. return (
  1073. <Tabs.Content
  1074. value={tab}
  1075. class="mt-3"
  1076. ref={(el: HTMLDivElement) => {
  1077. scroll = el
  1078. restoreScroll()
  1079. }}
  1080. onScroll={handleScroll}
  1081. >
  1082. <Show when={selection()}>
  1083. {(sel) => (
  1084. <div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
  1085. <button
  1086. type="button"
  1087. class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
  1088. onClick={() => {
  1089. const p = path()
  1090. if (!p) return
  1091. prompt.context.add({ type: "file", path: p, selection: sel() })
  1092. }}
  1093. >
  1094. <Icon name="plus-small" size="small" />
  1095. <span>Add {selectionLabel()} to context</span>
  1096. </button>
  1097. </div>
  1098. )}
  1099. </Show>
  1100. <Switch>
  1101. <Match when={state()?.loaded && isImage()}>
  1102. <div class="px-6 py-4 pb-40">
  1103. <img src={imageDataUrl()} alt={path()} class="max-w-full" />
  1104. </div>
  1105. </Match>
  1106. <Match when={state()?.loaded && isSvg()}>
  1107. <div class="flex flex-col gap-4 px-6 py-4">
  1108. <Dynamic
  1109. component={codeComponent}
  1110. file={{
  1111. name: path() ?? "",
  1112. contents: svgContent() ?? "",
  1113. cacheKey: cacheKey(),
  1114. }}
  1115. enableLineSelection
  1116. selectedLines={selectedLines()}
  1117. onLineSelected={(range: SelectedLineRange | null) => {
  1118. const p = path()
  1119. if (!p) return
  1120. file.setSelectedLines(p, range)
  1121. }}
  1122. overflow="scroll"
  1123. class="select-text"
  1124. />
  1125. <Show when={svgPreviewUrl()}>
  1126. <div class="flex justify-center pb-40">
  1127. <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
  1128. </div>
  1129. </Show>
  1130. </div>
  1131. </Match>
  1132. <Match when={state()?.loaded}>
  1133. <Dynamic
  1134. component={codeComponent}
  1135. file={{
  1136. name: path() ?? "",
  1137. contents: contents(),
  1138. cacheKey: cacheKey(),
  1139. }}
  1140. enableLineSelection
  1141. selectedLines={selectedLines()}
  1142. onLineSelected={(range: SelectedLineRange | null) => {
  1143. const p = path()
  1144. if (!p) return
  1145. file.setSelectedLines(p, range)
  1146. }}
  1147. overflow="scroll"
  1148. class="select-text pb-40"
  1149. />
  1150. </Match>
  1151. <Match when={state()?.loading}>
  1152. <div class="px-6 py-4 text-text-weak">Loading...</div>
  1153. </Match>
  1154. <Match when={state()?.error}>
  1155. {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
  1156. </Match>
  1157. </Switch>
  1158. </Tabs.Content>
  1159. )
  1160. }}
  1161. </For>
  1162. </Tabs>
  1163. <DragOverlay>
  1164. <Show when={store.activeDraggable}>
  1165. {(tab) => {
  1166. const path = createMemo(() => file.pathFromTab(tab()))
  1167. return (
  1168. <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
  1169. <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
  1170. </div>
  1171. )
  1172. }}
  1173. </Show>
  1174. </DragOverlay>
  1175. </DragDropProvider>
  1176. </div>
  1177. </Show>
  1178. </div>
  1179. <Show when={isDesktop() && layout.terminal.opened()}>
  1180. <div
  1181. class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
  1182. style={{ height: `${layout.terminal.height()}px` }}
  1183. >
  1184. <ResizeHandle
  1185. direction="vertical"
  1186. size={layout.terminal.height()}
  1187. min={100}
  1188. max={window.innerHeight * 0.6}
  1189. collapseThreshold={50}
  1190. onResize={layout.terminal.resize}
  1191. onCollapse={layout.terminal.close}
  1192. />
  1193. <DragDropProvider
  1194. onDragStart={handleTerminalDragStart}
  1195. onDragEnd={handleTerminalDragEnd}
  1196. onDragOver={handleTerminalDragOver}
  1197. collisionDetector={closestCenter}
  1198. >
  1199. <DragDropSensors />
  1200. <ConstrainDragYAxis />
  1201. <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
  1202. <Tabs.List class="h-10">
  1203. <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
  1204. <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
  1205. </SortableProvider>
  1206. <div class="h-full flex items-center justify-center">
  1207. <TooltipKeybind
  1208. title="New terminal"
  1209. keybind={command.keybind("terminal.new")}
  1210. class="flex items-center"
  1211. >
  1212. <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
  1213. </TooltipKeybind>
  1214. </div>
  1215. </Tabs.List>
  1216. <For each={terminal.all()}>
  1217. {(pty) => (
  1218. <Tabs.Content value={pty.id}>
  1219. <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
  1220. </Tabs.Content>
  1221. )}
  1222. </For>
  1223. </Tabs>
  1224. <DragOverlay>
  1225. <Show when={store.activeTerminalDraggable}>
  1226. {(draggedId) => {
  1227. const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
  1228. return (
  1229. <Show when={pty()}>
  1230. {(t) => (
  1231. <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
  1232. {t().title}
  1233. </div>
  1234. )}
  1235. </Show>
  1236. )
  1237. }}
  1238. </Show>
  1239. </DragOverlay>
  1240. </DragDropProvider>
  1241. </div>
  1242. </Show>
  1243. </div>
  1244. )
  1245. }