session.tsx 109 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872
  1. import { For, onCleanup, onMount, 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 FileSelection, 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 { Button } from "@opencode-ai/ui/button"
  12. import { Icon } from "@opencode-ai/ui/icon"
  13. import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
  14. import { DiffChanges } from "@opencode-ai/ui/diff-changes"
  15. import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
  16. import { Tabs } from "@opencode-ai/ui/tabs"
  17. import { useCodeComponent } from "@opencode-ai/ui/context/code"
  18. import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
  19. import { SessionTurn } from "@opencode-ai/ui/session-turn"
  20. import { BasicTool } from "@opencode-ai/ui/basic-tool"
  21. import { createAutoScroll } from "@opencode-ai/ui/hooks"
  22. import { SessionReview } from "@opencode-ai/ui/session-review"
  23. import { Mark } from "@opencode-ai/ui/logo"
  24. import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
  25. import type { DragEvent } from "@thisbeyond/solid-dnd"
  26. import { useSync } from "@/context/sync"
  27. import { useTerminal, type LocalPTY } from "@/context/terminal"
  28. import { useLayout } from "@/context/layout"
  29. import { Terminal } from "@/components/terminal"
  30. import { checksum, base64Encode } from "@opencode-ai/util/encode"
  31. import { findLast } from "@opencode-ai/util/array"
  32. import { useDialog } from "@opencode-ai/ui/context/dialog"
  33. import { DialogSelectFile } from "@/components/dialog-select-file"
  34. import FileTree from "@/components/file-tree"
  35. import { DialogSelectModel } from "@/components/dialog-select-model"
  36. import { DialogSelectMcp } from "@/components/dialog-select-mcp"
  37. import { DialogFork } from "@/components/dialog-fork"
  38. import { useCommand } from "@/context/command"
  39. import { useLanguage } from "@/context/language"
  40. import { useNavigate, useParams } from "@solidjs/router"
  41. import { UserMessage } from "@opencode-ai/sdk/v2"
  42. import type { FileDiff } from "@opencode-ai/sdk/v2/client"
  43. import { useSDK } from "@/context/sdk"
  44. import { usePrompt } from "@/context/prompt"
  45. import { useComments, type LineComment } from "@/context/comments"
  46. import { extractPromptFromParts } from "@/utils/prompt"
  47. import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
  48. import { usePermission } from "@/context/permission"
  49. import { decode64 } from "@/utils/base64"
  50. import { showToast } from "@opencode-ai/ui/toast"
  51. import {
  52. SessionHeader,
  53. SessionContextTab,
  54. SortableTab,
  55. FileVisual,
  56. SortableTerminalTab,
  57. NewSessionView,
  58. } from "@/components/session"
  59. import { navMark, navParams } from "@/utils/perf"
  60. import { same } from "@/utils/same"
  61. type DiffStyle = "unified" | "split"
  62. const handoff = {
  63. prompt: "",
  64. terminals: [] as string[],
  65. files: {} as Record<string, SelectedLineRange | null>,
  66. }
  67. interface SessionReviewTabProps {
  68. diffs: () => FileDiff[]
  69. view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
  70. diffStyle: DiffStyle
  71. onDiffStyleChange?: (style: DiffStyle) => void
  72. onViewFile?: (file: string) => void
  73. onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
  74. comments?: LineComment[]
  75. focusedComment?: { file: string; id: string } | null
  76. onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
  77. onScrollRef?: (el: HTMLDivElement) => void
  78. classes?: {
  79. root?: string
  80. header?: string
  81. container?: string
  82. }
  83. }
  84. function SessionReviewTab(props: SessionReviewTabProps) {
  85. let scroll: HTMLDivElement | undefined
  86. let frame: number | undefined
  87. let pending: { x: number; y: number } | undefined
  88. const sdk = useSDK()
  89. const readFile = async (path: string) => {
  90. return sdk.client.file
  91. .read({ path })
  92. .then((x) => x.data)
  93. .catch(() => undefined)
  94. }
  95. const restoreScroll = () => {
  96. const el = scroll
  97. if (!el) return
  98. const s = props.view().scroll("review")
  99. if (!s) return
  100. if (el.scrollTop !== s.y) el.scrollTop = s.y
  101. if (el.scrollLeft !== s.x) el.scrollLeft = s.x
  102. }
  103. const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
  104. pending = {
  105. x: event.currentTarget.scrollLeft,
  106. y: event.currentTarget.scrollTop,
  107. }
  108. if (frame !== undefined) return
  109. frame = requestAnimationFrame(() => {
  110. frame = undefined
  111. const next = pending
  112. pending = undefined
  113. if (!next) return
  114. props.view().setScroll("review", next)
  115. })
  116. }
  117. createEffect(
  118. on(
  119. () => props.diffs().length,
  120. () => {
  121. requestAnimationFrame(restoreScroll)
  122. },
  123. { defer: true },
  124. ),
  125. )
  126. onCleanup(() => {
  127. if (frame === undefined) return
  128. cancelAnimationFrame(frame)
  129. })
  130. return (
  131. <SessionReview
  132. scrollRef={(el) => {
  133. scroll = el
  134. props.onScrollRef?.(el)
  135. restoreScroll()
  136. }}
  137. onScroll={handleScroll}
  138. onDiffRendered={() => requestAnimationFrame(restoreScroll)}
  139. open={props.view().review.open()}
  140. onOpenChange={props.view().review.setOpen}
  141. classes={{
  142. root: props.classes?.root ?? "pb-40",
  143. header: props.classes?.header ?? "px-6",
  144. container: props.classes?.container ?? "px-6",
  145. }}
  146. diffs={props.diffs()}
  147. diffStyle={props.diffStyle}
  148. onDiffStyleChange={props.onDiffStyleChange}
  149. onViewFile={props.onViewFile}
  150. readFile={readFile}
  151. onLineComment={props.onLineComment}
  152. comments={props.comments}
  153. focusedComment={props.focusedComment}
  154. onFocusedCommentChange={props.onFocusedCommentChange}
  155. />
  156. )
  157. }
  158. export default function Page() {
  159. const layout = useLayout()
  160. const local = useLocal()
  161. const file = useFile()
  162. const sync = useSync()
  163. const terminal = useTerminal()
  164. const dialog = useDialog()
  165. const codeComponent = useCodeComponent()
  166. const command = useCommand()
  167. const language = useLanguage()
  168. const params = useParams()
  169. const navigate = useNavigate()
  170. const sdk = useSDK()
  171. const prompt = usePrompt()
  172. const comments = useComments()
  173. const permission = usePermission()
  174. const request = createMemo(() => {
  175. const sessionID = params.id
  176. if (!sessionID) return
  177. const next = sync.data.permission[sessionID]?.[0]
  178. if (!next) return
  179. if (next.tool) return
  180. return next
  181. })
  182. const [ui, setUi] = createStore({
  183. responding: false,
  184. pendingMessage: undefined as string | undefined,
  185. scrollGesture: 0,
  186. autoCreated: false,
  187. })
  188. createEffect(
  189. on(
  190. () => request()?.id,
  191. () => setUi("responding", false),
  192. { defer: true },
  193. ),
  194. )
  195. const decide = (response: "once" | "always" | "reject") => {
  196. const perm = request()
  197. if (!perm) return
  198. if (ui.responding) return
  199. setUi("responding", true)
  200. sdk.client.permission
  201. .respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
  202. .catch((err: unknown) => {
  203. const message = err instanceof Error ? err.message : String(err)
  204. showToast({ title: language.t("common.requestFailed"), description: message })
  205. })
  206. .finally(() => setUi("responding", false))
  207. }
  208. const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
  209. const tabs = createMemo(() => layout.tabs(sessionKey))
  210. const view = createMemo(() => layout.view(sessionKey))
  211. if (import.meta.env.DEV) {
  212. createEffect(
  213. on(
  214. () => [params.dir, params.id] as const,
  215. ([dir, id], prev) => {
  216. if (!id) return
  217. navParams({ dir, from: prev?.[1], to: id })
  218. },
  219. ),
  220. )
  221. createEffect(() => {
  222. const id = params.id
  223. if (!id) return
  224. if (!prompt.ready()) return
  225. navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
  226. })
  227. createEffect(() => {
  228. const id = params.id
  229. if (!id) return
  230. if (!terminal.ready()) return
  231. navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
  232. })
  233. createEffect(() => {
  234. const id = params.id
  235. if (!id) return
  236. if (!file.ready()) return
  237. navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
  238. })
  239. createEffect(() => {
  240. const id = params.id
  241. if (!id) return
  242. if (sync.data.message[id] === undefined) return
  243. navMark({ dir: params.dir, to: id, name: "session:data-ready" })
  244. })
  245. }
  246. const isDesktop = createMediaQuery("(min-width: 768px)")
  247. function normalizeTab(tab: string) {
  248. if (!tab.startsWith("file://")) return tab
  249. return file.tab(tab)
  250. }
  251. function normalizeTabs(list: string[]) {
  252. const seen = new Set<string>()
  253. const next: string[] = []
  254. for (const item of list) {
  255. const value = normalizeTab(item)
  256. if (seen.has(value)) continue
  257. seen.add(value)
  258. next.push(value)
  259. }
  260. return next
  261. }
  262. const openTab = (value: string) => {
  263. const next = normalizeTab(value)
  264. tabs().open(next)
  265. const path = file.pathFromTab(next)
  266. if (path) file.load(path)
  267. }
  268. createEffect(() => {
  269. const active = tabs().active()
  270. if (!active) return
  271. const path = file.pathFromTab(active)
  272. if (path) file.load(path)
  273. })
  274. createEffect(() => {
  275. const current = tabs().all()
  276. if (current.length === 0) return
  277. const next = normalizeTabs(current)
  278. if (same(current, next)) return
  279. tabs().setAll(next)
  280. const active = tabs().active()
  281. if (!active) return
  282. if (!active.startsWith("file://")) return
  283. const normalized = normalizeTab(active)
  284. if (active === normalized) return
  285. tabs().setActive(normalized)
  286. })
  287. const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
  288. const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
  289. const hasReview = createMemo(() => reviewCount() > 0)
  290. const revertMessageID = createMemo(() => info()?.revert?.messageID)
  291. const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
  292. const messagesReady = createMemo(() => {
  293. const id = params.id
  294. if (!id) return true
  295. return sync.data.message[id] !== undefined
  296. })
  297. const historyMore = createMemo(() => {
  298. const id = params.id
  299. if (!id) return false
  300. return sync.session.history.more(id)
  301. })
  302. const historyLoading = createMemo(() => {
  303. const id = params.id
  304. if (!id) return false
  305. return sync.session.history.loading(id)
  306. })
  307. const emptyUserMessages: UserMessage[] = []
  308. const userMessages = createMemo(
  309. () => messages().filter((m) => m.role === "user") as UserMessage[],
  310. emptyUserMessages,
  311. { equals: same },
  312. )
  313. const visibleUserMessages = createMemo(
  314. () => {
  315. const revert = revertMessageID()
  316. if (!revert) return userMessages()
  317. return userMessages().filter((m) => m.id < revert)
  318. },
  319. emptyUserMessages,
  320. {
  321. equals: same,
  322. },
  323. )
  324. const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
  325. createEffect(
  326. on(
  327. () => lastUserMessage()?.id,
  328. () => {
  329. const msg = lastUserMessage()
  330. if (!msg) return
  331. if (msg.agent) local.agent.set(msg.agent)
  332. if (msg.model) local.model.set(msg.model)
  333. },
  334. ),
  335. )
  336. const [store, setStore] = createStore({
  337. activeDraggable: undefined as string | undefined,
  338. activeTerminalDraggable: undefined as string | undefined,
  339. expanded: {} as Record<string, boolean>,
  340. messageId: undefined as string | undefined,
  341. turnStart: 0,
  342. mobileTab: "session" as "session" | "review",
  343. newSessionWorktree: "main",
  344. promptHeight: 0,
  345. })
  346. const renderedUserMessages = createMemo(
  347. () => {
  348. const msgs = visibleUserMessages()
  349. const start = store.turnStart
  350. if (start <= 0) return msgs
  351. if (start >= msgs.length) return emptyUserMessages
  352. return msgs.slice(start)
  353. },
  354. emptyUserMessages,
  355. {
  356. equals: same,
  357. },
  358. )
  359. const newSessionWorktree = createMemo(() => {
  360. if (store.newSessionWorktree === "create") return "create"
  361. const project = sync.project
  362. if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
  363. return "main"
  364. })
  365. const activeMessage = createMemo(() => {
  366. if (!store.messageId) return lastUserMessage()
  367. const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
  368. return found ?? lastUserMessage()
  369. })
  370. const setActiveMessage = (message: UserMessage | undefined) => {
  371. setStore("messageId", message?.id)
  372. }
  373. function navigateMessageByOffset(offset: number) {
  374. const msgs = visibleUserMessages()
  375. if (msgs.length === 0) return
  376. const current = activeMessage()
  377. const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
  378. const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
  379. if (targetIndex < 0 || targetIndex >= msgs.length) return
  380. scrollToMessage(msgs[targetIndex], "auto")
  381. }
  382. const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
  383. const emptyDiffFiles: string[] = []
  384. const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
  385. const diffsReady = createMemo(() => {
  386. const id = params.id
  387. if (!id) return true
  388. if (!hasReview()) return true
  389. return sync.data.session_diff[id] !== undefined
  390. })
  391. const idle = { type: "idle" as const }
  392. let inputRef!: HTMLDivElement
  393. let promptDock: HTMLDivElement | undefined
  394. let scroller: HTMLDivElement | undefined
  395. const scrollGestureWindowMs = 250
  396. const markScrollGesture = (target?: EventTarget | null) => {
  397. const root = scroller
  398. if (!root) return
  399. const el = target instanceof Element ? target : undefined
  400. const nested = el?.closest("[data-scrollable]")
  401. if (nested && nested !== root) return
  402. setUi("scrollGesture", Date.now())
  403. }
  404. const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
  405. createEffect(() => {
  406. if (!params.id) return
  407. sync.session.sync(params.id)
  408. })
  409. createEffect(() => {
  410. if (!view().terminal.opened()) {
  411. setUi("autoCreated", false)
  412. return
  413. }
  414. if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
  415. terminal.new()
  416. setUi("autoCreated", true)
  417. })
  418. createEffect(
  419. on(
  420. () => terminal.all().length,
  421. (count, prevCount) => {
  422. if (prevCount !== undefined && prevCount > 0 && count === 0) {
  423. if (view().terminal.opened()) {
  424. view().terminal.toggle()
  425. }
  426. }
  427. },
  428. ),
  429. )
  430. createEffect(
  431. on(
  432. () => terminal.active(),
  433. (activeId) => {
  434. if (!activeId || !view().terminal.opened()) return
  435. // Immediately remove focus
  436. if (document.activeElement instanceof HTMLElement) {
  437. document.activeElement.blur()
  438. }
  439. const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
  440. const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
  441. if (!element) return
  442. // Find and focus the ghostty textarea (the actual input element)
  443. const textarea = element.querySelector("textarea") as HTMLTextAreaElement
  444. if (textarea) {
  445. textarea.focus()
  446. return
  447. }
  448. // Fallback: focus container and dispatch pointer event
  449. element.focus()
  450. element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
  451. },
  452. ),
  453. )
  454. createEffect(
  455. on(
  456. () => visibleUserMessages().at(-1)?.id,
  457. (lastId, prevLastId) => {
  458. if (lastId && prevLastId && lastId > prevLastId) {
  459. setStore("messageId", undefined)
  460. }
  461. },
  462. { defer: true },
  463. ),
  464. )
  465. const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
  466. createEffect(
  467. on(
  468. () => params.id,
  469. () => {
  470. setStore("messageId", undefined)
  471. setStore("expanded", {})
  472. },
  473. { defer: true },
  474. ),
  475. )
  476. createEffect(() => {
  477. const id = lastUserMessage()?.id
  478. if (!id) return
  479. setStore("expanded", id, status().type !== "idle")
  480. })
  481. const selectionPreview = (path: string, selection: FileSelection) => {
  482. const content = file.get(path)?.content?.content
  483. if (!content) return undefined
  484. const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
  485. const end = Math.max(selection.startLine, selection.endLine)
  486. const lines = content.split("\n").slice(start - 1, end)
  487. if (lines.length === 0) return undefined
  488. return lines.slice(0, 2).join("\n")
  489. }
  490. const addSelectionToContext = (path: string, selection: FileSelection) => {
  491. const preview = selectionPreview(path, selection)
  492. prompt.context.add({ type: "file", path, selection, preview })
  493. }
  494. const addCommentToContext = (input: {
  495. file: string
  496. selection: SelectedLineRange
  497. comment: string
  498. preview?: string
  499. origin?: "review" | "file"
  500. }) => {
  501. const selection = selectionFromLines(input.selection)
  502. const preview = input.preview ?? selectionPreview(input.file, selection)
  503. const saved = comments.add({
  504. file: input.file,
  505. selection: input.selection,
  506. comment: input.comment,
  507. })
  508. prompt.context.add({
  509. type: "file",
  510. path: input.file,
  511. selection,
  512. comment: input.comment,
  513. commentID: saved.id,
  514. commentOrigin: input.origin,
  515. preview,
  516. })
  517. }
  518. command.register(() => [
  519. {
  520. id: "session.new",
  521. title: language.t("command.session.new"),
  522. category: language.t("command.category.session"),
  523. keybind: "mod+shift+s",
  524. slash: "new",
  525. onSelect: () => navigate(`/${params.dir}/session`),
  526. },
  527. {
  528. id: "file.open",
  529. title: language.t("command.file.open"),
  530. description: language.t("command.file.open.description"),
  531. category: language.t("command.category.file"),
  532. keybind: "mod+p",
  533. slash: "open",
  534. onSelect: () => dialog.show(() => <DialogSelectFile />),
  535. },
  536. {
  537. id: "context.addSelection",
  538. title: language.t("command.context.addSelection"),
  539. description: language.t("command.context.addSelection.description"),
  540. category: language.t("command.category.context"),
  541. keybind: "mod+shift+l",
  542. disabled: (() => {
  543. const active = tabs().active()
  544. if (!active) return true
  545. const path = file.pathFromTab(active)
  546. if (!path) return true
  547. return file.selectedLines(path) == null
  548. })(),
  549. onSelect: () => {
  550. const active = tabs().active()
  551. if (!active) return
  552. const path = file.pathFromTab(active)
  553. if (!path) return
  554. const range = file.selectedLines(path)
  555. if (!range) {
  556. showToast({
  557. title: language.t("toast.context.noLineSelection.title"),
  558. description: language.t("toast.context.noLineSelection.description"),
  559. })
  560. return
  561. }
  562. addSelectionToContext(path, selectionFromLines(range))
  563. },
  564. },
  565. {
  566. id: "terminal.toggle",
  567. title: language.t("command.terminal.toggle"),
  568. description: "",
  569. category: language.t("command.category.view"),
  570. keybind: "ctrl+`",
  571. slash: "terminal",
  572. onSelect: () => view().terminal.toggle(),
  573. },
  574. {
  575. id: "review.toggle",
  576. title: language.t("command.review.toggle"),
  577. description: "",
  578. category: language.t("command.category.view"),
  579. keybind: "mod+shift+r",
  580. onSelect: () => view().reviewPanel.toggle(),
  581. },
  582. {
  583. id: "terminal.new",
  584. title: language.t("command.terminal.new"),
  585. description: language.t("command.terminal.new.description"),
  586. category: language.t("command.category.terminal"),
  587. keybind: "ctrl+alt+t",
  588. onSelect: () => {
  589. if (terminal.all().length > 0) terminal.new()
  590. view().terminal.open()
  591. },
  592. },
  593. {
  594. id: "steps.toggle",
  595. title: language.t("command.steps.toggle"),
  596. description: language.t("command.steps.toggle.description"),
  597. category: language.t("command.category.view"),
  598. keybind: "mod+e",
  599. slash: "steps",
  600. disabled: !params.id,
  601. onSelect: () => {
  602. const msg = activeMessage()
  603. if (!msg) return
  604. setStore("expanded", msg.id, (open: boolean | undefined) => !open)
  605. },
  606. },
  607. {
  608. id: "message.previous",
  609. title: language.t("command.message.previous"),
  610. description: language.t("command.message.previous.description"),
  611. category: language.t("command.category.session"),
  612. keybind: "mod+arrowup",
  613. disabled: !params.id,
  614. onSelect: () => navigateMessageByOffset(-1),
  615. },
  616. {
  617. id: "message.next",
  618. title: language.t("command.message.next"),
  619. description: language.t("command.message.next.description"),
  620. category: language.t("command.category.session"),
  621. keybind: "mod+arrowdown",
  622. disabled: !params.id,
  623. onSelect: () => navigateMessageByOffset(1),
  624. },
  625. {
  626. id: "model.choose",
  627. title: language.t("command.model.choose"),
  628. description: language.t("command.model.choose.description"),
  629. category: language.t("command.category.model"),
  630. keybind: "mod+'",
  631. slash: "model",
  632. onSelect: () => dialog.show(() => <DialogSelectModel />),
  633. },
  634. {
  635. id: "mcp.toggle",
  636. title: language.t("command.mcp.toggle"),
  637. description: language.t("command.mcp.toggle.description"),
  638. category: language.t("command.category.mcp"),
  639. keybind: "mod+;",
  640. slash: "mcp",
  641. onSelect: () => dialog.show(() => <DialogSelectMcp />),
  642. },
  643. {
  644. id: "agent.cycle",
  645. title: language.t("command.agent.cycle"),
  646. description: language.t("command.agent.cycle.description"),
  647. category: language.t("command.category.agent"),
  648. keybind: "mod+.",
  649. slash: "agent",
  650. onSelect: () => local.agent.move(1),
  651. },
  652. {
  653. id: "agent.cycle.reverse",
  654. title: language.t("command.agent.cycle.reverse"),
  655. description: language.t("command.agent.cycle.reverse.description"),
  656. category: language.t("command.category.agent"),
  657. keybind: "shift+mod+.",
  658. onSelect: () => local.agent.move(-1),
  659. },
  660. {
  661. id: "model.variant.cycle",
  662. title: language.t("command.model.variant.cycle"),
  663. description: language.t("command.model.variant.cycle.description"),
  664. category: language.t("command.category.model"),
  665. keybind: "shift+mod+d",
  666. onSelect: () => {
  667. local.model.variant.cycle()
  668. },
  669. },
  670. {
  671. id: "permissions.autoaccept",
  672. title:
  673. params.id && permission.isAutoAccepting(params.id, sdk.directory)
  674. ? language.t("command.permissions.autoaccept.disable")
  675. : language.t("command.permissions.autoaccept.enable"),
  676. category: language.t("command.category.permissions"),
  677. keybind: "mod+shift+a",
  678. disabled: !params.id || !permission.permissionsEnabled(),
  679. onSelect: () => {
  680. const sessionID = params.id
  681. if (!sessionID) return
  682. permission.toggleAutoAccept(sessionID, sdk.directory)
  683. showToast({
  684. title: permission.isAutoAccepting(sessionID, sdk.directory)
  685. ? language.t("toast.permissions.autoaccept.on.title")
  686. : language.t("toast.permissions.autoaccept.off.title"),
  687. description: permission.isAutoAccepting(sessionID, sdk.directory)
  688. ? language.t("toast.permissions.autoaccept.on.description")
  689. : language.t("toast.permissions.autoaccept.off.description"),
  690. })
  691. },
  692. },
  693. {
  694. id: "session.undo",
  695. title: language.t("command.session.undo"),
  696. description: language.t("command.session.undo.description"),
  697. category: language.t("command.category.session"),
  698. slash: "undo",
  699. disabled: !params.id || visibleUserMessages().length === 0,
  700. onSelect: async () => {
  701. const sessionID = params.id
  702. if (!sessionID) return
  703. if (status()?.type !== "idle") {
  704. await sdk.client.session.abort({ sessionID }).catch(() => {})
  705. }
  706. const revert = info()?.revert?.messageID
  707. // Find the last user message that's not already reverted
  708. const message = findLast(userMessages(), (x) => !revert || x.id < revert)
  709. if (!message) return
  710. await sdk.client.session.revert({ sessionID, messageID: message.id })
  711. // Restore the prompt from the reverted message
  712. const parts = sync.data.part[message.id]
  713. if (parts) {
  714. const restored = extractPromptFromParts(parts, { directory: sdk.directory })
  715. prompt.set(restored)
  716. }
  717. // Navigate to the message before the reverted one (which will be the new last visible message)
  718. const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
  719. setActiveMessage(priorMessage)
  720. },
  721. },
  722. {
  723. id: "session.redo",
  724. title: language.t("command.session.redo"),
  725. description: language.t("command.session.redo.description"),
  726. category: language.t("command.category.session"),
  727. slash: "redo",
  728. disabled: !params.id || !info()?.revert?.messageID,
  729. onSelect: async () => {
  730. const sessionID = params.id
  731. if (!sessionID) return
  732. const revertMessageID = info()?.revert?.messageID
  733. if (!revertMessageID) return
  734. const nextMessage = userMessages().find((x) => x.id > revertMessageID)
  735. if (!nextMessage) {
  736. // Full unrevert - restore all messages and navigate to last
  737. await sdk.client.session.unrevert({ sessionID })
  738. prompt.reset()
  739. // Navigate to the last message (the one that was at the revert point)
  740. const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
  741. setActiveMessage(lastMsg)
  742. return
  743. }
  744. // Partial redo - move forward to next message
  745. await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
  746. // Navigate to the message before the new revert point
  747. const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
  748. setActiveMessage(priorMsg)
  749. },
  750. },
  751. {
  752. id: "session.compact",
  753. title: language.t("command.session.compact"),
  754. description: language.t("command.session.compact.description"),
  755. category: language.t("command.category.session"),
  756. slash: "compact",
  757. disabled: !params.id || visibleUserMessages().length === 0,
  758. onSelect: async () => {
  759. const sessionID = params.id
  760. if (!sessionID) return
  761. const model = local.model.current()
  762. if (!model) {
  763. showToast({
  764. title: language.t("toast.model.none.title"),
  765. description: language.t("toast.model.none.description"),
  766. })
  767. return
  768. }
  769. await sdk.client.session.summarize({
  770. sessionID,
  771. modelID: model.id,
  772. providerID: model.provider.id,
  773. })
  774. },
  775. },
  776. {
  777. id: "session.fork",
  778. title: language.t("command.session.fork"),
  779. description: language.t("command.session.fork.description"),
  780. category: language.t("command.category.session"),
  781. slash: "fork",
  782. disabled: !params.id || visibleUserMessages().length === 0,
  783. onSelect: () => dialog.show(() => <DialogFork />),
  784. },
  785. ...(sync.data.config.share !== "disabled"
  786. ? [
  787. {
  788. id: "session.share",
  789. title: language.t("command.session.share"),
  790. description: language.t("command.session.share.description"),
  791. category: language.t("command.category.session"),
  792. slash: "share",
  793. disabled: !params.id || !!info()?.share?.url,
  794. onSelect: async () => {
  795. if (!params.id) return
  796. await sdk.client.session
  797. .share({ sessionID: params.id })
  798. .then((res) => {
  799. navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
  800. showToast({
  801. title: language.t("toast.session.share.copyFailed.title"),
  802. variant: "error",
  803. }),
  804. )
  805. })
  806. .then(() =>
  807. showToast({
  808. title: language.t("toast.session.share.success.title"),
  809. description: language.t("toast.session.share.success.description"),
  810. variant: "success",
  811. }),
  812. )
  813. .catch(() =>
  814. showToast({
  815. title: language.t("toast.session.share.failed.title"),
  816. description: language.t("toast.session.share.failed.description"),
  817. variant: "error",
  818. }),
  819. )
  820. },
  821. },
  822. {
  823. id: "session.unshare",
  824. title: language.t("command.session.unshare"),
  825. description: language.t("command.session.unshare.description"),
  826. category: language.t("command.category.session"),
  827. slash: "unshare",
  828. disabled: !params.id || !info()?.share?.url,
  829. onSelect: async () => {
  830. if (!params.id) return
  831. await sdk.client.session
  832. .unshare({ sessionID: params.id })
  833. .then(() =>
  834. showToast({
  835. title: language.t("toast.session.unshare.success.title"),
  836. description: language.t("toast.session.unshare.success.description"),
  837. variant: "success",
  838. }),
  839. )
  840. .catch(() =>
  841. showToast({
  842. title: language.t("toast.session.unshare.failed.title"),
  843. description: language.t("toast.session.unshare.failed.description"),
  844. variant: "error",
  845. }),
  846. )
  847. },
  848. },
  849. ]
  850. : []),
  851. ])
  852. const handleKeyDown = (event: KeyboardEvent) => {
  853. const activeElement = document.activeElement as HTMLElement | undefined
  854. if (activeElement) {
  855. const isProtected = activeElement.closest("[data-prevent-autofocus]")
  856. const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable
  857. if (isProtected || isInput) return
  858. }
  859. if (dialog.active) return
  860. if (activeElement === inputRef) {
  861. if (event.key === "Escape") inputRef?.blur()
  862. return
  863. }
  864. // Don't autofocus chat if terminal panel is open
  865. if (view().terminal.opened()) return
  866. // Only treat explicit scroll keys as potential "user scroll" gestures.
  867. if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
  868. markScrollGesture()
  869. return
  870. }
  871. if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
  872. inputRef?.focus()
  873. }
  874. }
  875. const handleDragStart = (event: unknown) => {
  876. const id = getDraggableId(event)
  877. if (!id) return
  878. setStore("activeDraggable", id)
  879. }
  880. const handleDragOver = (event: DragEvent) => {
  881. const { draggable, droppable } = event
  882. if (draggable && droppable) {
  883. const currentTabs = tabs().all()
  884. const fromIndex = currentTabs?.indexOf(draggable.id.toString())
  885. const toIndex = currentTabs?.indexOf(droppable.id.toString())
  886. if (fromIndex !== toIndex && toIndex !== undefined) {
  887. tabs().move(draggable.id.toString(), toIndex)
  888. }
  889. }
  890. }
  891. const handleDragEnd = () => {
  892. setStore("activeDraggable", undefined)
  893. }
  894. const handleTerminalDragStart = (event: unknown) => {
  895. const id = getDraggableId(event)
  896. if (!id) return
  897. setStore("activeTerminalDraggable", id)
  898. }
  899. const handleTerminalDragOver = (event: DragEvent) => {
  900. const { draggable, droppable } = event
  901. if (draggable && droppable) {
  902. const terminals = terminal.all()
  903. const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
  904. const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
  905. if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
  906. terminal.move(draggable.id.toString(), toIndex)
  907. }
  908. }
  909. }
  910. const handleTerminalDragEnd = () => {
  911. setStore("activeTerminalDraggable", undefined)
  912. const activeId = terminal.active()
  913. if (!activeId) return
  914. setTimeout(() => {
  915. const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
  916. const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
  917. if (!element) return
  918. // Find and focus the ghostty textarea (the actual input element)
  919. const textarea = element.querySelector("textarea") as HTMLTextAreaElement
  920. if (textarea) {
  921. textarea.focus()
  922. return
  923. }
  924. // Fallback: focus container and dispatch pointer event
  925. element.focus()
  926. element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
  927. }, 0)
  928. }
  929. const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
  930. const openedTabs = createMemo(() =>
  931. tabs()
  932. .all()
  933. .filter((tab) => tab !== "context"),
  934. )
  935. const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review")
  936. const showTabs = createMemo(() => view().reviewPanel.opened())
  937. const [tree, setTree] = createStore({
  938. fileTreeTab: "changes" as "changes" | "all",
  939. reviewScroll: undefined as HTMLDivElement | undefined,
  940. pendingDiff: undefined as string | undefined,
  941. })
  942. const fileTreeTab = () => tree.fileTreeTab
  943. const setFileTreeTab = (value: "changes" | "all") => setTree("fileTreeTab", value)
  944. const reviewScroll = () => tree.reviewScroll
  945. const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
  946. const pendingDiff = () => tree.pendingDiff
  947. const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
  948. createEffect(() => {
  949. if (!layout.fileTree.opened()) return
  950. setFileTreeTab("changes")
  951. })
  952. const setFileTreeTabValue = (value: string) => {
  953. if (value !== "changes" && value !== "all") return
  954. setFileTreeTab(value)
  955. }
  956. const reviewDiffId = (path: string) => {
  957. const sum = checksum(path)
  958. if (!sum) return
  959. return `session-review-diff-${sum}`
  960. }
  961. const reviewDiffTop = (path: string) => {
  962. const root = reviewScroll()
  963. if (!root) return
  964. const id = reviewDiffId(path)
  965. if (!id) return
  966. const el = document.getElementById(id)
  967. if (!(el instanceof HTMLElement)) return
  968. if (!root.contains(el)) return
  969. const a = el.getBoundingClientRect()
  970. const b = root.getBoundingClientRect()
  971. return a.top - b.top + root.scrollTop
  972. }
  973. const scrollToReviewDiff = (path: string) => {
  974. const root = reviewScroll()
  975. if (!root) return false
  976. const top = reviewDiffTop(path)
  977. if (top === undefined) return false
  978. view().setScroll("review", { x: root.scrollLeft, y: top })
  979. root.scrollTo({ top, behavior: "auto" })
  980. return true
  981. }
  982. const focusReviewDiff = (path: string) => {
  983. const current = view().review.open() ?? []
  984. if (!current.includes(path)) view().review.setOpen([...current, path])
  985. setPendingDiff(path)
  986. }
  987. createEffect(() => {
  988. const pending = pendingDiff()
  989. if (!pending) return
  990. if (!reviewScroll()) return
  991. if (!diffsReady()) return
  992. const attempt = (count: number) => {
  993. if (pendingDiff() !== pending) return
  994. if (count > 60) {
  995. setPendingDiff(undefined)
  996. return
  997. }
  998. const root = reviewScroll()
  999. if (!root) {
  1000. requestAnimationFrame(() => attempt(count + 1))
  1001. return
  1002. }
  1003. if (!scrollToReviewDiff(pending)) {
  1004. requestAnimationFrame(() => attempt(count + 1))
  1005. return
  1006. }
  1007. const top = reviewDiffTop(pending)
  1008. if (top === undefined) {
  1009. requestAnimationFrame(() => attempt(count + 1))
  1010. return
  1011. }
  1012. if (Math.abs(root.scrollTop - top) <= 1) {
  1013. setPendingDiff(undefined)
  1014. return
  1015. }
  1016. requestAnimationFrame(() => attempt(count + 1))
  1017. }
  1018. requestAnimationFrame(() => attempt(0))
  1019. })
  1020. const activeTab = createMemo(() => {
  1021. const active = tabs().active()
  1022. if (layout.fileTree.opened() && fileTreeTab() === "all") {
  1023. if (active && active !== "review" && active !== "context") return normalizeTab(active)
  1024. const first = openedTabs()[0]
  1025. if (first) return first
  1026. return "review"
  1027. }
  1028. if (active) return normalizeTab(active)
  1029. if (hasReview()) return "review"
  1030. const first = openedTabs()[0]
  1031. if (first) return first
  1032. if (contextOpen()) return "context"
  1033. return "review"
  1034. })
  1035. createEffect(() => {
  1036. if (!layout.ready()) return
  1037. if (tabs().active()) return
  1038. if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
  1039. tabs().setActive(activeTab())
  1040. })
  1041. createEffect(() => {
  1042. if (!layout.fileTree.opened()) return
  1043. if (fileTreeTab() !== "all") return
  1044. const first = openedTabs()[0]
  1045. if (!first) return
  1046. const active = tabs().active()
  1047. if (active && active !== "review" && active !== "context") return
  1048. tabs().setActive(first)
  1049. })
  1050. createEffect(() => {
  1051. const id = params.id
  1052. if (!id) return
  1053. if (!hasReview()) return
  1054. const wants = isDesktop()
  1055. ? view().reviewPanel.opened() &&
  1056. (layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review")
  1057. : store.mobileTab === "review"
  1058. if (!wants) return
  1059. if (diffsReady()) return
  1060. sync.session.diff(id)
  1061. })
  1062. const autoScroll = createAutoScroll({
  1063. working: () => true,
  1064. overflowAnchor: "dynamic",
  1065. })
  1066. const resumeScroll = () => {
  1067. setStore("messageId", undefined)
  1068. autoScroll.forceScrollToBottom()
  1069. }
  1070. // When the user returns to the bottom, treat the active message as "latest".
  1071. createEffect(
  1072. on(
  1073. autoScroll.userScrolled,
  1074. (scrolled) => {
  1075. if (scrolled) return
  1076. setStore("messageId", undefined)
  1077. },
  1078. { defer: true },
  1079. ),
  1080. )
  1081. let scrollSpyFrame: number | undefined
  1082. let scrollSpyTarget: HTMLDivElement | undefined
  1083. const anchor = (id: string) => `message-${id}`
  1084. const setScrollRef = (el: HTMLDivElement | undefined) => {
  1085. scroller = el
  1086. autoScroll.scrollRef(el)
  1087. }
  1088. const turnInit = 20
  1089. const turnBatch = 20
  1090. let turnHandle: number | undefined
  1091. let turnIdle = false
  1092. function cancelTurnBackfill() {
  1093. const handle = turnHandle
  1094. if (handle === undefined) return
  1095. turnHandle = undefined
  1096. if (turnIdle && window.cancelIdleCallback) {
  1097. window.cancelIdleCallback(handle)
  1098. return
  1099. }
  1100. clearTimeout(handle)
  1101. }
  1102. function scheduleTurnBackfill() {
  1103. if (turnHandle !== undefined) return
  1104. if (store.turnStart <= 0) return
  1105. if (window.requestIdleCallback) {
  1106. turnIdle = true
  1107. turnHandle = window.requestIdleCallback(() => {
  1108. turnHandle = undefined
  1109. backfillTurns()
  1110. })
  1111. return
  1112. }
  1113. turnIdle = false
  1114. turnHandle = window.setTimeout(() => {
  1115. turnHandle = undefined
  1116. backfillTurns()
  1117. }, 0)
  1118. }
  1119. function backfillTurns() {
  1120. const start = store.turnStart
  1121. if (start <= 0) return
  1122. const next = start - turnBatch
  1123. const nextStart = next > 0 ? next : 0
  1124. const el = scroller
  1125. if (!el) {
  1126. setStore("turnStart", nextStart)
  1127. scheduleTurnBackfill()
  1128. return
  1129. }
  1130. const beforeTop = el.scrollTop
  1131. const beforeHeight = el.scrollHeight
  1132. setStore("turnStart", nextStart)
  1133. requestAnimationFrame(() => {
  1134. const delta = el.scrollHeight - beforeHeight
  1135. if (delta) el.scrollTop = beforeTop + delta
  1136. })
  1137. scheduleTurnBackfill()
  1138. }
  1139. createEffect(
  1140. on(
  1141. () => [params.id, messagesReady()] as const,
  1142. ([id, ready]) => {
  1143. cancelTurnBackfill()
  1144. setStore("turnStart", 0)
  1145. if (!id || !ready) return
  1146. const len = visibleUserMessages().length
  1147. const start = len > turnInit ? len - turnInit : 0
  1148. setStore("turnStart", start)
  1149. scheduleTurnBackfill()
  1150. },
  1151. { defer: true },
  1152. ),
  1153. )
  1154. createResizeObserver(
  1155. () => promptDock,
  1156. ({ height }) => {
  1157. const next = Math.ceil(height)
  1158. if (next === store.promptHeight) return
  1159. const el = scroller
  1160. const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
  1161. setStore("promptHeight", next)
  1162. if (stick && el) {
  1163. requestAnimationFrame(() => {
  1164. el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
  1165. })
  1166. }
  1167. },
  1168. )
  1169. const updateHash = (id: string) => {
  1170. window.history.replaceState(null, "", `#${anchor(id)}`)
  1171. }
  1172. createEffect(() => {
  1173. const sessionID = params.id
  1174. if (!sessionID) return
  1175. const raw = sessionStorage.getItem("opencode.pendingMessage")
  1176. if (!raw) return
  1177. const parts = raw.split("|")
  1178. const pendingSessionID = parts[0]
  1179. const messageID = parts[1]
  1180. if (!pendingSessionID || !messageID) return
  1181. if (pendingSessionID !== sessionID) return
  1182. sessionStorage.removeItem("opencode.pendingMessage")
  1183. setUi("pendingMessage", messageID)
  1184. })
  1185. const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
  1186. const root = scroller
  1187. if (!root) return false
  1188. const a = el.getBoundingClientRect()
  1189. const b = root.getBoundingClientRect()
  1190. const top = a.top - b.top + root.scrollTop
  1191. root.scrollTo({ top, behavior })
  1192. return true
  1193. }
  1194. const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
  1195. setActiveMessage(message)
  1196. const msgs = visibleUserMessages()
  1197. const index = msgs.findIndex((m) => m.id === message.id)
  1198. if (index !== -1 && index < store.turnStart) {
  1199. setStore("turnStart", index)
  1200. scheduleTurnBackfill()
  1201. requestAnimationFrame(() => {
  1202. const el = document.getElementById(anchor(message.id))
  1203. if (!el) {
  1204. requestAnimationFrame(() => {
  1205. const next = document.getElementById(anchor(message.id))
  1206. if (!next) return
  1207. scrollToElement(next, behavior)
  1208. })
  1209. return
  1210. }
  1211. scrollToElement(el, behavior)
  1212. })
  1213. updateHash(message.id)
  1214. return
  1215. }
  1216. const el = document.getElementById(anchor(message.id))
  1217. if (!el) {
  1218. updateHash(message.id)
  1219. requestAnimationFrame(() => {
  1220. const next = document.getElementById(anchor(message.id))
  1221. if (!next) return
  1222. if (!scrollToElement(next, behavior)) return
  1223. })
  1224. return
  1225. }
  1226. if (scrollToElement(el, behavior)) {
  1227. updateHash(message.id)
  1228. return
  1229. }
  1230. requestAnimationFrame(() => {
  1231. const next = document.getElementById(anchor(message.id))
  1232. if (!next) return
  1233. if (!scrollToElement(next, behavior)) return
  1234. })
  1235. updateHash(message.id)
  1236. }
  1237. const applyHash = (behavior: ScrollBehavior) => {
  1238. const hash = window.location.hash.slice(1)
  1239. if (!hash) {
  1240. autoScroll.forceScrollToBottom()
  1241. return
  1242. }
  1243. const match = hash.match(/^message-(.+)$/)
  1244. if (match) {
  1245. const msg = visibleUserMessages().find((m) => m.id === match[1])
  1246. if (msg) {
  1247. scrollToMessage(msg, behavior)
  1248. return
  1249. }
  1250. // If we have a message hash but the message isn't loaded/rendered yet,
  1251. // don't fall back to "bottom". We'll retry once messages arrive.
  1252. return
  1253. }
  1254. const target = document.getElementById(hash)
  1255. if (target) {
  1256. scrollToElement(target, behavior)
  1257. return
  1258. }
  1259. autoScroll.forceScrollToBottom()
  1260. }
  1261. const closestMessage = (node: Element | null): HTMLElement | null => {
  1262. if (!node) return null
  1263. const match = node.closest?.("[data-message-id]") as HTMLElement | null
  1264. if (match) return match
  1265. const root = node.getRootNode?.()
  1266. if (root instanceof ShadowRoot) return closestMessage(root.host)
  1267. return null
  1268. }
  1269. const getActiveMessageId = (container: HTMLDivElement) => {
  1270. const rect = container.getBoundingClientRect()
  1271. if (!rect.width || !rect.height) return
  1272. const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
  1273. const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
  1274. const hit = document.elementFromPoint(x, y)
  1275. const host = closestMessage(hit)
  1276. const id = host?.dataset.messageId
  1277. if (id) return id
  1278. // Fallback: DOM query (handles edge hit-testing cases)
  1279. const cutoff = container.scrollTop + 100
  1280. const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
  1281. let last: string | undefined
  1282. for (const node of nodes) {
  1283. const next = node.dataset.messageId
  1284. if (!next) continue
  1285. if (node.offsetTop > cutoff) break
  1286. last = next
  1287. }
  1288. return last
  1289. }
  1290. const scheduleScrollSpy = (container: HTMLDivElement) => {
  1291. scrollSpyTarget = container
  1292. if (scrollSpyFrame !== undefined) return
  1293. scrollSpyFrame = requestAnimationFrame(() => {
  1294. scrollSpyFrame = undefined
  1295. const target = scrollSpyTarget
  1296. scrollSpyTarget = undefined
  1297. if (!target) return
  1298. const id = getActiveMessageId(target)
  1299. if (!id) return
  1300. if (id === store.messageId) return
  1301. setStore("messageId", id)
  1302. })
  1303. }
  1304. createEffect(() => {
  1305. const sessionID = params.id
  1306. const ready = messagesReady()
  1307. if (!sessionID || !ready) return
  1308. requestAnimationFrame(() => {
  1309. applyHash("auto")
  1310. })
  1311. })
  1312. // Retry message navigation once the target message is actually loaded.
  1313. createEffect(() => {
  1314. const sessionID = params.id
  1315. const ready = messagesReady()
  1316. if (!sessionID || !ready) return
  1317. // dependencies
  1318. visibleUserMessages().length
  1319. store.turnStart
  1320. const targetId =
  1321. ui.pendingMessage ??
  1322. (() => {
  1323. const hash = window.location.hash.slice(1)
  1324. const match = hash.match(/^message-(.+)$/)
  1325. if (!match) return undefined
  1326. return match[1]
  1327. })()
  1328. if (!targetId) return
  1329. if (store.messageId === targetId) return
  1330. const msg = visibleUserMessages().find((m) => m.id === targetId)
  1331. if (!msg) return
  1332. if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
  1333. requestAnimationFrame(() => scrollToMessage(msg, "auto"))
  1334. })
  1335. createEffect(() => {
  1336. const sessionID = params.id
  1337. const ready = messagesReady()
  1338. if (!sessionID || !ready) return
  1339. const handler = () => requestAnimationFrame(() => applyHash("auto"))
  1340. window.addEventListener("hashchange", handler)
  1341. onCleanup(() => window.removeEventListener("hashchange", handler))
  1342. })
  1343. createEffect(() => {
  1344. document.addEventListener("keydown", handleKeyDown)
  1345. })
  1346. const previewPrompt = () =>
  1347. prompt
  1348. .current()
  1349. .map((part) => {
  1350. if (part.type === "file") return `[file:${part.path}]`
  1351. if (part.type === "agent") return `@${part.name}`
  1352. if (part.type === "image") return `[image:${part.filename}]`
  1353. return part.content
  1354. })
  1355. .join("")
  1356. .trim()
  1357. createEffect(() => {
  1358. if (!prompt.ready()) return
  1359. handoff.prompt = previewPrompt()
  1360. })
  1361. createEffect(() => {
  1362. if (!terminal.ready()) return
  1363. language.locale()
  1364. const label = (pty: LocalPTY) => {
  1365. const title = pty.title
  1366. const number = pty.titleNumber
  1367. const match = title.match(/^Terminal (\d+)$/)
  1368. const parsed = match ? Number(match[1]) : undefined
  1369. const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
  1370. if (title && !isDefaultTitle) return title
  1371. if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
  1372. if (title) return title
  1373. return language.t("terminal.title")
  1374. }
  1375. handoff.terminals = terminal.all().map(label)
  1376. })
  1377. createEffect(() => {
  1378. if (!file.ready()) return
  1379. handoff.files = Object.fromEntries(
  1380. tabs()
  1381. .all()
  1382. .flatMap((tab) => {
  1383. const path = file.pathFromTab(tab)
  1384. if (!path) return []
  1385. return [[path, file.selectedLines(path) ?? null] as const]
  1386. }),
  1387. )
  1388. })
  1389. onCleanup(() => {
  1390. cancelTurnBackfill()
  1391. document.removeEventListener("keydown", handleKeyDown)
  1392. if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
  1393. })
  1394. return (
  1395. <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
  1396. <SessionHeader />
  1397. <div class="flex-1 min-h-0 flex flex-col md:flex-row">
  1398. {/* Mobile tab bar - only shown on mobile when user opened review */}
  1399. <Show when={!isDesktop() && view().reviewPanel.opened()}>
  1400. <Tabs class="h-auto">
  1401. <Tabs.List>
  1402. <Tabs.Trigger
  1403. value="session"
  1404. class="w-1/2"
  1405. classes={{ button: "w-full" }}
  1406. onClick={() => setStore("mobileTab", "session")}
  1407. >
  1408. {language.t("session.tab.session")}
  1409. </Tabs.Trigger>
  1410. <Tabs.Trigger
  1411. value="review"
  1412. class="w-1/2 !border-r-0"
  1413. classes={{ button: "w-full" }}
  1414. onClick={() => setStore("mobileTab", "review")}
  1415. >
  1416. <Switch>
  1417. <Match when={hasReview()}>
  1418. {language.t("session.review.filesChanged", { count: reviewCount() })}
  1419. </Match>
  1420. <Match when={true}>{language.t("session.tab.review")}</Match>
  1421. </Switch>
  1422. </Tabs.Trigger>
  1423. </Tabs.List>
  1424. </Tabs>
  1425. </Show>
  1426. {/* Session panel */}
  1427. <div
  1428. classList={{
  1429. "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
  1430. "flex-1 md:flex-none pt-6 md:pt-3": true,
  1431. }}
  1432. style={{
  1433. width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
  1434. "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
  1435. }}
  1436. >
  1437. <div class="flex-1 min-h-0 overflow-hidden">
  1438. <Switch>
  1439. <Match when={params.id}>
  1440. <Show when={activeMessage()}>
  1441. <Show
  1442. when={!mobileReview()}
  1443. fallback={
  1444. <div class="relative h-full overflow-hidden">
  1445. <Switch>
  1446. <Match when={hasReview()}>
  1447. <Show
  1448. when={diffsReady()}
  1449. fallback={
  1450. <div class="px-4 py-4 text-text-weak">
  1451. {language.t("session.review.loadingChanges")}
  1452. </div>
  1453. }
  1454. >
  1455. <SessionReviewTab
  1456. diffs={diffs}
  1457. view={view}
  1458. diffStyle="unified"
  1459. onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
  1460. comments={comments.all()}
  1461. focusedComment={comments.focus()}
  1462. onFocusedCommentChange={comments.setFocus}
  1463. onViewFile={(path) => {
  1464. const value = file.tab(path)
  1465. tabs().open(value)
  1466. file.load(path)
  1467. }}
  1468. classes={{
  1469. root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
  1470. header: "px-4",
  1471. container: "px-4",
  1472. }}
  1473. />
  1474. </Show>
  1475. </Match>
  1476. <Match when={true}>
  1477. <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
  1478. <Mark class="w-14 opacity-10" />
  1479. <div class="text-14-regular text-text-weak max-w-56">
  1480. {language.t("session.review.empty")}
  1481. </div>
  1482. </div>
  1483. </Match>
  1484. </Switch>
  1485. </div>
  1486. }
  1487. >
  1488. <div class="relative w-full h-full min-w-0">
  1489. <div
  1490. class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
  1491. classList={{
  1492. "opacity-100 translate-y-0 scale-100": autoScroll.userScrolled(),
  1493. "opacity-0 translate-y-2 scale-95 pointer-events-none": !autoScroll.userScrolled(),
  1494. }}
  1495. >
  1496. <button
  1497. class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
  1498. onClick={() => {
  1499. setStore("messageId", undefined)
  1500. autoScroll.forceScrollToBottom()
  1501. window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
  1502. }}
  1503. >
  1504. <Icon name="arrow-down-to-line" />
  1505. </button>
  1506. </div>
  1507. <div
  1508. ref={setScrollRef}
  1509. onWheel={(e) => markScrollGesture(e.target)}
  1510. onTouchMove={(e) => markScrollGesture(e.target)}
  1511. onPointerDown={(e) => {
  1512. if (e.target !== e.currentTarget) return
  1513. markScrollGesture(e.target)
  1514. }}
  1515. onScroll={(e) => {
  1516. autoScroll.handleScroll()
  1517. if (!hasScrollGesture()) return
  1518. markScrollGesture(e.target)
  1519. if (isDesktop()) scheduleScrollSpy(e.currentTarget)
  1520. }}
  1521. onClick={autoScroll.handleInteraction}
  1522. class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
  1523. style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }}
  1524. >
  1525. <Show when={info()?.title || info()?.parentID}>
  1526. <div
  1527. classList={{
  1528. "sticky top-0 z-30 bg-background-stronger": true,
  1529. "w-full": true,
  1530. "px-4 md:px-6": true,
  1531. "md:max-w-200 md:mx-auto": !showTabs(),
  1532. }}
  1533. >
  1534. <div class="h-10 flex items-center gap-1">
  1535. <Show when={info()?.parentID}>
  1536. <IconButton
  1537. tabIndex={-1}
  1538. icon="arrow-left"
  1539. variant="ghost"
  1540. onClick={() => {
  1541. navigate(`/${params.dir}/session/${info()?.parentID}`)
  1542. }}
  1543. aria-label={language.t("common.goBack")}
  1544. />
  1545. </Show>
  1546. <Show when={info()?.title}>
  1547. <h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
  1548. </Show>
  1549. </div>
  1550. </div>
  1551. </Show>
  1552. <div
  1553. ref={autoScroll.contentRef}
  1554. role="log"
  1555. 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]"
  1556. classList={{
  1557. "w-full": true,
  1558. "md:max-w-200 md:mx-auto": !showTabs(),
  1559. "mt-0.5": !showTabs(),
  1560. "mt-0": showTabs(),
  1561. }}
  1562. >
  1563. <Show when={store.turnStart > 0}>
  1564. <div class="w-full flex justify-center">
  1565. <Button
  1566. variant="ghost"
  1567. size="large"
  1568. class="text-12-medium opacity-50"
  1569. onClick={() => setStore("turnStart", 0)}
  1570. >
  1571. {language.t("session.messages.renderEarlier")}
  1572. </Button>
  1573. </div>
  1574. </Show>
  1575. <Show when={historyMore()}>
  1576. <div class="w-full flex justify-center">
  1577. <Button
  1578. variant="ghost"
  1579. size="large"
  1580. class="text-12-medium opacity-50"
  1581. disabled={historyLoading()}
  1582. onClick={() => {
  1583. const id = params.id
  1584. if (!id) return
  1585. setStore("turnStart", 0)
  1586. sync.session.history.loadMore(id)
  1587. }}
  1588. >
  1589. {historyLoading()
  1590. ? language.t("session.messages.loadingEarlier")
  1591. : language.t("session.messages.loadEarlier")}
  1592. </Button>
  1593. </div>
  1594. </Show>
  1595. <For each={renderedUserMessages()}>
  1596. {(message) => {
  1597. if (import.meta.env.DEV) {
  1598. onMount(() => {
  1599. const id = params.id
  1600. if (!id) return
  1601. navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
  1602. })
  1603. }
  1604. return (
  1605. <div
  1606. id={anchor(message.id)}
  1607. data-message-id={message.id}
  1608. classList={{
  1609. "min-w-0 w-full max-w-full": true,
  1610. "md:max-w-200": !showTabs(),
  1611. }}
  1612. >
  1613. <SessionTurn
  1614. sessionID={params.id!}
  1615. messageID={message.id}
  1616. lastUserMessageID={lastUserMessage()?.id}
  1617. stepsExpanded={store.expanded[message.id] ?? false}
  1618. onStepsExpandedToggle={() =>
  1619. setStore("expanded", message.id, (open: boolean | undefined) => !open)
  1620. }
  1621. classes={{
  1622. root: "min-w-0 w-full relative",
  1623. content: "flex flex-col justify-between !overflow-visible",
  1624. container: "w-full px-4 md:px-6",
  1625. }}
  1626. />
  1627. </div>
  1628. )
  1629. }}
  1630. </For>
  1631. </div>
  1632. </div>
  1633. </div>
  1634. </Show>
  1635. </Show>
  1636. </Match>
  1637. <Match when={true}>
  1638. <NewSessionView
  1639. worktree={newSessionWorktree()}
  1640. onWorktreeChange={(value) => {
  1641. if (value === "create") {
  1642. setStore("newSessionWorktree", value)
  1643. return
  1644. }
  1645. setStore("newSessionWorktree", "main")
  1646. const target = value === "main" ? sync.project?.worktree : value
  1647. if (!target) return
  1648. if (target === sync.data.path.directory) return
  1649. layout.projects.open(target)
  1650. navigate(`/${base64Encode(target)}/session`)
  1651. }}
  1652. />
  1653. </Match>
  1654. </Switch>
  1655. </div>
  1656. {/* Prompt input */}
  1657. <div
  1658. ref={(el) => (promptDock = el)}
  1659. class="absolute inset-x-0 bottom-0 pt-12 pb-4 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"
  1660. >
  1661. <div
  1662. classList={{
  1663. "w-full px-4 pointer-events-auto": true,
  1664. "md:max-w-200": !showTabs(),
  1665. }}
  1666. >
  1667. <Show when={request()} keyed>
  1668. {(perm) => (
  1669. <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
  1670. <BasicTool
  1671. icon="checklist"
  1672. locked
  1673. defaultOpen
  1674. trigger={{
  1675. title: language.t("notification.permission.title"),
  1676. subtitle:
  1677. perm.permission === "doom_loop"
  1678. ? language.t("settings.permissions.tool.doom_loop.title")
  1679. : perm.permission,
  1680. }}
  1681. >
  1682. <Show when={perm.patterns.length > 0}>
  1683. <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
  1684. <For each={perm.patterns}>
  1685. {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
  1686. </For>
  1687. </div>
  1688. </Show>
  1689. <Show when={perm.permission === "doom_loop"}>
  1690. <div class="text-12-regular text-text-weak pb-2 px-3">
  1691. {language.t("settings.permissions.tool.doom_loop.description")}
  1692. </div>
  1693. </Show>
  1694. </BasicTool>
  1695. <div data-component="permission-prompt">
  1696. <div data-slot="permission-actions">
  1697. <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}>
  1698. {language.t("ui.permission.deny")}
  1699. </Button>
  1700. <Button
  1701. variant="secondary"
  1702. size="small"
  1703. onClick={() => decide("always")}
  1704. disabled={ui.responding}
  1705. >
  1706. {language.t("ui.permission.allowAlways")}
  1707. </Button>
  1708. <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}>
  1709. {language.t("ui.permission.allowOnce")}
  1710. </Button>
  1711. </div>
  1712. </div>
  1713. </div>
  1714. )}
  1715. </Show>
  1716. <Show
  1717. when={prompt.ready()}
  1718. fallback={
  1719. <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
  1720. {handoff.prompt || language.t("prompt.loading")}
  1721. </div>
  1722. }
  1723. >
  1724. <PromptInput
  1725. ref={(el) => {
  1726. inputRef = el
  1727. }}
  1728. newSessionWorktree={newSessionWorktree()}
  1729. onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
  1730. onSubmit={resumeScroll}
  1731. />
  1732. </Show>
  1733. </div>
  1734. </div>
  1735. <Show when={isDesktop() && showTabs()}>
  1736. <ResizeHandle
  1737. direction="horizontal"
  1738. size={layout.session.width()}
  1739. min={450}
  1740. max={window.innerWidth * 0.45}
  1741. onResize={layout.session.resize}
  1742. />
  1743. </Show>
  1744. </div>
  1745. {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
  1746. <Show when={isDesktop() && showTabs()}>
  1747. <aside
  1748. id="review-panel"
  1749. aria-label={language.t("session.panel.reviewAndFiles")}
  1750. class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
  1751. >
  1752. <div class="flex-1 min-w-0 h-full">
  1753. <Show
  1754. when={layout.fileTree.opened() && fileTreeTab() === "changes"}
  1755. fallback={
  1756. <DragDropProvider
  1757. onDragStart={handleDragStart}
  1758. onDragEnd={handleDragEnd}
  1759. onDragOver={handleDragOver}
  1760. collisionDetector={closestCenter}
  1761. >
  1762. <DragDropSensors />
  1763. <ConstrainDragYAxis />
  1764. <Tabs value={activeTab()} onChange={openTab}>
  1765. <div class="sticky top-0 shrink-0 flex">
  1766. <Tabs.List>
  1767. <Show when={!layout.fileTree.opened()}>
  1768. <Tabs.Trigger value="review">
  1769. <div class="flex items-center gap-3">
  1770. <Show when={diffs()}>
  1771. <DiffChanges changes={diffs()} variant="bars" />
  1772. </Show>
  1773. <div class="flex items-center gap-1.5">
  1774. <div>{language.t("session.tab.review")}</div>
  1775. <Show when={info()?.summary?.files}>
  1776. <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
  1777. {info()?.summary?.files ?? 0}
  1778. </div>
  1779. </Show>
  1780. </div>
  1781. </div>
  1782. </Tabs.Trigger>
  1783. </Show>
  1784. <Show when={!layout.fileTree.opened() && contextOpen()}>
  1785. <Tabs.Trigger
  1786. value="context"
  1787. closeButton={
  1788. <Tooltip value={language.t("common.closeTab")} placement="bottom">
  1789. <IconButton
  1790. icon="close"
  1791. variant="ghost"
  1792. onClick={() => tabs().close("context")}
  1793. aria-label={language.t("common.closeTab")}
  1794. />
  1795. </Tooltip>
  1796. }
  1797. hideCloseButton
  1798. onMiddleClick={() => tabs().close("context")}
  1799. >
  1800. <div class="flex items-center gap-2">
  1801. <SessionContextUsage variant="indicator" />
  1802. <div>{language.t("session.tab.context")}</div>
  1803. </div>
  1804. </Tabs.Trigger>
  1805. </Show>
  1806. <SortableProvider ids={openedTabs()}>
  1807. <For each={openedTabs()}>
  1808. {(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}
  1809. </For>
  1810. </SortableProvider>
  1811. <div class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-l border-border-weak-base px-3">
  1812. <TooltipKeybind
  1813. title={language.t("command.file.open")}
  1814. keybind={command.keybind("file.open")}
  1815. class="flex items-center"
  1816. >
  1817. <IconButton
  1818. icon="plus-small"
  1819. variant="ghost"
  1820. iconSize="large"
  1821. onClick={() => dialog.show(() => <DialogSelectFile mode="files" />)}
  1822. aria-label={language.t("command.file.open")}
  1823. />
  1824. </TooltipKeybind>
  1825. </div>
  1826. </Tabs.List>
  1827. </div>
  1828. <Show when={!layout.fileTree.opened()}>
  1829. <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
  1830. <Show when={activeTab() === "review"}>
  1831. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  1832. <Switch>
  1833. <Match when={hasReview()}>
  1834. <Show
  1835. when={diffsReady()}
  1836. fallback={
  1837. <div class="px-6 py-4 text-text-weak">
  1838. {language.t("session.review.loadingChanges")}
  1839. </div>
  1840. }
  1841. >
  1842. <SessionReviewTab
  1843. diffs={diffs}
  1844. view={view}
  1845. diffStyle={layout.review.diffStyle()}
  1846. onDiffStyleChange={layout.review.setDiffStyle}
  1847. onScrollRef={setReviewScroll}
  1848. onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
  1849. comments={comments.all()}
  1850. focusedComment={comments.focus()}
  1851. onFocusedCommentChange={comments.setFocus}
  1852. onViewFile={(path) => {
  1853. const value = file.tab(path)
  1854. tabs().open(value)
  1855. file.load(path)
  1856. }}
  1857. />
  1858. </Show>
  1859. </Match>
  1860. <Match when={true}>
  1861. <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
  1862. <Mark class="w-14 opacity-10" />
  1863. <div class="text-14-regular text-text-weak max-w-56">
  1864. {language.t("session.review.empty")}
  1865. </div>
  1866. </div>
  1867. </Match>
  1868. </Switch>
  1869. </div>
  1870. </Show>
  1871. </Tabs.Content>
  1872. </Show>
  1873. <Show when={layout.fileTree.opened() && fileTreeTab() === "all" && openedTabs().length === 0}>
  1874. <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
  1875. <Show when={activeTab() === "review"}>
  1876. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  1877. <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
  1878. <Mark class="w-14 opacity-10" />
  1879. <div class="text-14-regular text-text-weak max-w-56">
  1880. {language.t("session.files.selectToOpen")}
  1881. </div>
  1882. </div>
  1883. </div>
  1884. </Show>
  1885. </Tabs.Content>
  1886. </Show>
  1887. <Show when={!layout.fileTree.opened() && contextOpen()}>
  1888. <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
  1889. <Show when={activeTab() === "context"}>
  1890. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  1891. <SessionContextTab
  1892. messages={messages}
  1893. visibleUserMessages={visibleUserMessages}
  1894. view={view}
  1895. info={info}
  1896. />
  1897. </div>
  1898. </Show>
  1899. </Tabs.Content>
  1900. </Show>
  1901. <For each={openedTabs()}>
  1902. {(tab) => {
  1903. let scroll: HTMLDivElement | undefined
  1904. let scrollFrame: number | undefined
  1905. let pending: { x: number; y: number } | undefined
  1906. let codeScroll: HTMLElement[] = []
  1907. const path = createMemo(() => file.pathFromTab(tab))
  1908. const state = createMemo(() => {
  1909. const p = path()
  1910. if (!p) return
  1911. return file.get(p)
  1912. })
  1913. const contents = createMemo(() => state()?.content?.content ?? "")
  1914. const cacheKey = createMemo(() => checksum(contents()))
  1915. const isImage = createMemo(() => {
  1916. const c = state()?.content
  1917. return (
  1918. c?.encoding === "base64" &&
  1919. c?.mimeType?.startsWith("image/") &&
  1920. c?.mimeType !== "image/svg+xml"
  1921. )
  1922. })
  1923. const isSvg = createMemo(() => {
  1924. const c = state()?.content
  1925. return c?.mimeType === "image/svg+xml"
  1926. })
  1927. const svgContent = createMemo(() => {
  1928. if (!isSvg()) return
  1929. const c = state()?.content
  1930. if (!c) return
  1931. if (c.encoding !== "base64") return c.content
  1932. return decode64(c.content)
  1933. })
  1934. const svgDecodeFailed = createMemo(() => {
  1935. if (!isSvg()) return false
  1936. const c = state()?.content
  1937. if (!c) return false
  1938. if (c.encoding !== "base64") return false
  1939. return svgContent() === undefined
  1940. })
  1941. const svgToast = { shown: false }
  1942. createEffect(() => {
  1943. if (!svgDecodeFailed()) return
  1944. if (svgToast.shown) return
  1945. svgToast.shown = true
  1946. showToast({
  1947. variant: "error",
  1948. title: language.t("toast.file.loadFailed.title"),
  1949. description: "Invalid base64 content.",
  1950. })
  1951. })
  1952. const svgPreviewUrl = createMemo(() => {
  1953. if (!isSvg()) return
  1954. const c = state()?.content
  1955. if (!c) return
  1956. if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
  1957. return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
  1958. })
  1959. const imageDataUrl = createMemo(() => {
  1960. if (!isImage()) return
  1961. const c = state()?.content
  1962. return `data:${c?.mimeType};base64,${c?.content}`
  1963. })
  1964. const selectedLines = createMemo(() => {
  1965. const p = path()
  1966. if (!p) return null
  1967. if (file.ready()) return file.selectedLines(p) ?? null
  1968. return handoff.files[p] ?? null
  1969. })
  1970. let wrap: HTMLDivElement | undefined
  1971. const fileComments = createMemo(() => {
  1972. const p = path()
  1973. if (!p) return []
  1974. return comments.list(p)
  1975. })
  1976. const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
  1977. const [note, setNote] = createStore({
  1978. openedComment: null as string | null,
  1979. commenting: null as SelectedLineRange | null,
  1980. draft: "",
  1981. positions: {} as Record<string, number>,
  1982. draftTop: undefined as number | undefined,
  1983. })
  1984. const openedComment = () => note.openedComment
  1985. const setOpenedComment = (
  1986. value:
  1987. | typeof note.openedComment
  1988. | ((value: typeof note.openedComment) => typeof note.openedComment),
  1989. ) => setNote("openedComment", value)
  1990. const commenting = () => note.commenting
  1991. const setCommenting = (
  1992. value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting),
  1993. ) => setNote("commenting", value)
  1994. const draft = () => note.draft
  1995. const setDraft = (
  1996. value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft),
  1997. ) => setNote("draft", value)
  1998. const positions = () => note.positions
  1999. const setPositions = (
  2000. value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions),
  2001. ) => setNote("positions", value)
  2002. const draftTop = () => note.draftTop
  2003. const setDraftTop = (
  2004. value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop),
  2005. ) => setNote("draftTop", value)
  2006. const commentLabel = (range: SelectedLineRange) => {
  2007. const start = Math.min(range.start, range.end)
  2008. const end = Math.max(range.start, range.end)
  2009. if (start === end) return `line ${start}`
  2010. return `lines ${start}-${end}`
  2011. }
  2012. const getRoot = () => {
  2013. const el = wrap
  2014. if (!el) return
  2015. const host = el.querySelector("diffs-container")
  2016. if (!(host instanceof HTMLElement)) return
  2017. const root = host.shadowRoot
  2018. if (!root) return
  2019. return root
  2020. }
  2021. const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
  2022. const line = Math.max(range.start, range.end)
  2023. const node = root.querySelector(`[data-line="${line}"]`)
  2024. if (!(node instanceof HTMLElement)) return
  2025. return node
  2026. }
  2027. const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
  2028. const wrapperRect = wrapper.getBoundingClientRect()
  2029. const rect = marker.getBoundingClientRect()
  2030. return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
  2031. }
  2032. const updateComments = () => {
  2033. const el = wrap
  2034. const root = getRoot()
  2035. if (!el || !root) {
  2036. setPositions({})
  2037. setDraftTop(undefined)
  2038. return
  2039. }
  2040. const next: Record<string, number> = {}
  2041. for (const comment of fileComments()) {
  2042. const marker = findMarker(root, comment.selection)
  2043. if (!marker) continue
  2044. next[comment.id] = markerTop(el, marker)
  2045. }
  2046. setPositions(next)
  2047. const range = commenting()
  2048. if (!range) {
  2049. setDraftTop(undefined)
  2050. return
  2051. }
  2052. const marker = findMarker(root, range)
  2053. if (!marker) {
  2054. setDraftTop(undefined)
  2055. return
  2056. }
  2057. setDraftTop(markerTop(el, marker))
  2058. }
  2059. const scheduleComments = () => {
  2060. requestAnimationFrame(updateComments)
  2061. }
  2062. createEffect(() => {
  2063. fileComments()
  2064. scheduleComments()
  2065. })
  2066. createEffect(() => {
  2067. const range = commenting()
  2068. scheduleComments()
  2069. if (!range) return
  2070. setDraft("")
  2071. })
  2072. createEffect(() => {
  2073. const focus = comments.focus()
  2074. const p = path()
  2075. if (!focus || !p) return
  2076. if (focus.file !== p) return
  2077. if (activeTab() !== tab) return
  2078. const target = fileComments().find((comment) => comment.id === focus.id)
  2079. if (!target) return
  2080. setOpenedComment(target.id)
  2081. setCommenting(null)
  2082. file.setSelectedLines(p, target.selection)
  2083. requestAnimationFrame(() => comments.clearFocus())
  2084. })
  2085. const renderCode = (source: string, wrapperClass: string) => (
  2086. <div
  2087. ref={(el) => {
  2088. wrap = el
  2089. scheduleComments()
  2090. }}
  2091. class={`relative overflow-hidden ${wrapperClass}`}
  2092. >
  2093. <Dynamic
  2094. component={codeComponent}
  2095. file={{
  2096. name: path() ?? "",
  2097. contents: source,
  2098. cacheKey: cacheKey(),
  2099. }}
  2100. enableLineSelection
  2101. selectedLines={selectedLines()}
  2102. commentedLines={commentedLines()}
  2103. onRendered={() => {
  2104. requestAnimationFrame(restoreScroll)
  2105. requestAnimationFrame(scheduleComments)
  2106. }}
  2107. onLineSelected={(range: SelectedLineRange | null) => {
  2108. const p = path()
  2109. if (!p) return
  2110. file.setSelectedLines(p, range)
  2111. if (!range) setCommenting(null)
  2112. }}
  2113. onLineSelectionEnd={(range: SelectedLineRange | null) => {
  2114. if (!range) {
  2115. setCommenting(null)
  2116. return
  2117. }
  2118. setOpenedComment(null)
  2119. setCommenting(range)
  2120. }}
  2121. overflow="scroll"
  2122. class="select-text"
  2123. />
  2124. <For each={fileComments()}>
  2125. {(comment) => (
  2126. <LineCommentView
  2127. id={comment.id}
  2128. top={positions()[comment.id]}
  2129. open={openedComment() === comment.id}
  2130. comment={comment.comment}
  2131. selection={commentLabel(comment.selection)}
  2132. onMouseEnter={() => {
  2133. const p = path()
  2134. if (!p) return
  2135. file.setSelectedLines(p, comment.selection)
  2136. }}
  2137. onClick={() => {
  2138. const p = path()
  2139. if (!p) return
  2140. setCommenting(null)
  2141. setOpenedComment((current) => (current === comment.id ? null : comment.id))
  2142. file.setSelectedLines(p, comment.selection)
  2143. }}
  2144. />
  2145. )}
  2146. </For>
  2147. <Show when={commenting()}>
  2148. {(range) => (
  2149. <Show when={draftTop() !== undefined}>
  2150. <LineCommentEditor
  2151. top={draftTop()}
  2152. value={draft()}
  2153. selection={commentLabel(range())}
  2154. onInput={(value) => setDraft(value)}
  2155. onCancel={() => setCommenting(null)}
  2156. onSubmit={(value) => {
  2157. const p = path()
  2158. if (!p) return
  2159. addCommentToContext({
  2160. file: p,
  2161. selection: range(),
  2162. comment: value,
  2163. origin: "file",
  2164. })
  2165. setCommenting(null)
  2166. }}
  2167. onPopoverFocusOut={(e: FocusEvent) => {
  2168. const current = e.currentTarget as HTMLDivElement
  2169. const target = e.relatedTarget
  2170. if (target instanceof Node && current.contains(target)) return
  2171. setTimeout(() => {
  2172. if (!document.activeElement || !current.contains(document.activeElement)) {
  2173. setCommenting(null)
  2174. }
  2175. }, 0)
  2176. }}
  2177. />
  2178. </Show>
  2179. )}
  2180. </Show>
  2181. </div>
  2182. )
  2183. const getCodeScroll = () => {
  2184. const el = scroll
  2185. if (!el) return []
  2186. const host = el.querySelector("diffs-container")
  2187. if (!(host instanceof HTMLElement)) return []
  2188. const root = host.shadowRoot
  2189. if (!root) return []
  2190. return Array.from(root.querySelectorAll("[data-code]")).filter(
  2191. (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
  2192. )
  2193. }
  2194. const queueScrollUpdate = (next: { x: number; y: number }) => {
  2195. pending = next
  2196. if (scrollFrame !== undefined) return
  2197. scrollFrame = requestAnimationFrame(() => {
  2198. scrollFrame = undefined
  2199. const next = pending
  2200. pending = undefined
  2201. if (!next) return
  2202. view().setScroll(tab, next)
  2203. })
  2204. }
  2205. const handleCodeScroll = (event: Event) => {
  2206. const el = scroll
  2207. if (!el) return
  2208. const target = event.currentTarget
  2209. if (!(target instanceof HTMLElement)) return
  2210. queueScrollUpdate({
  2211. x: target.scrollLeft,
  2212. y: el.scrollTop,
  2213. })
  2214. }
  2215. const syncCodeScroll = () => {
  2216. const next = getCodeScroll()
  2217. if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
  2218. for (const item of codeScroll) {
  2219. item.removeEventListener("scroll", handleCodeScroll)
  2220. }
  2221. codeScroll = next
  2222. for (const item of codeScroll) {
  2223. item.addEventListener("scroll", handleCodeScroll)
  2224. }
  2225. }
  2226. const restoreScroll = () => {
  2227. const el = scroll
  2228. if (!el) return
  2229. const s = view()?.scroll(tab)
  2230. if (!s) return
  2231. syncCodeScroll()
  2232. if (codeScroll.length > 0) {
  2233. for (const item of codeScroll) {
  2234. if (item.scrollLeft !== s.x) item.scrollLeft = s.x
  2235. }
  2236. }
  2237. if (el.scrollTop !== s.y) el.scrollTop = s.y
  2238. if (codeScroll.length > 0) return
  2239. if (el.scrollLeft !== s.x) el.scrollLeft = s.x
  2240. }
  2241. const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
  2242. if (codeScroll.length === 0) syncCodeScroll()
  2243. queueScrollUpdate({
  2244. x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
  2245. y: event.currentTarget.scrollTop,
  2246. })
  2247. }
  2248. createEffect(
  2249. on(
  2250. () => state()?.loaded,
  2251. (loaded) => {
  2252. if (!loaded) return
  2253. requestAnimationFrame(restoreScroll)
  2254. },
  2255. { defer: true },
  2256. ),
  2257. )
  2258. createEffect(
  2259. on(
  2260. () => file.ready(),
  2261. (ready) => {
  2262. if (!ready) return
  2263. requestAnimationFrame(restoreScroll)
  2264. },
  2265. { defer: true },
  2266. ),
  2267. )
  2268. createEffect(
  2269. on(
  2270. () => tabs().active() === tab,
  2271. (active) => {
  2272. if (!active) return
  2273. if (!state()?.loaded) return
  2274. requestAnimationFrame(restoreScroll)
  2275. },
  2276. ),
  2277. )
  2278. onCleanup(() => {
  2279. for (const item of codeScroll) {
  2280. item.removeEventListener("scroll", handleCodeScroll)
  2281. }
  2282. if (scrollFrame === undefined) return
  2283. cancelAnimationFrame(scrollFrame)
  2284. })
  2285. return (
  2286. <Tabs.Content
  2287. value={tab}
  2288. class="mt-3 relative"
  2289. ref={(el: HTMLDivElement) => {
  2290. scroll = el
  2291. restoreScroll()
  2292. }}
  2293. onScroll={handleScroll}
  2294. >
  2295. <Switch>
  2296. <Match when={state()?.loaded && isImage()}>
  2297. <div class="px-6 py-4 pb-40">
  2298. <img
  2299. src={imageDataUrl()}
  2300. alt={path()}
  2301. class="max-w-full"
  2302. onLoad={() => requestAnimationFrame(restoreScroll)}
  2303. />
  2304. </div>
  2305. </Match>
  2306. <Match when={state()?.loaded && isSvg()}>
  2307. <div class="flex flex-col gap-4 px-6 py-4">
  2308. {renderCode(svgContent() ?? "", "")}
  2309. <Show when={svgPreviewUrl()}>
  2310. <div class="flex justify-center pb-40">
  2311. <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
  2312. </div>
  2313. </Show>
  2314. </div>
  2315. </Match>
  2316. <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
  2317. <Match when={state()?.loading}>
  2318. <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
  2319. </Match>
  2320. <Match when={state()?.error}>
  2321. {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
  2322. </Match>
  2323. </Switch>
  2324. </Tabs.Content>
  2325. )
  2326. }}
  2327. </For>
  2328. </Tabs>
  2329. <DragOverlay>
  2330. <Show when={store.activeDraggable}>
  2331. {(tab) => {
  2332. const path = createMemo(() => file.pathFromTab(tab()))
  2333. return (
  2334. <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
  2335. <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
  2336. </div>
  2337. )
  2338. }}
  2339. </Show>
  2340. </DragOverlay>
  2341. </DragDropProvider>
  2342. }
  2343. >
  2344. <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
  2345. <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
  2346. <Switch>
  2347. <Match when={hasReview()}>
  2348. <Show
  2349. when={diffsReady()}
  2350. fallback={
  2351. <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
  2352. }
  2353. >
  2354. <SessionReviewTab
  2355. diffs={diffs}
  2356. view={view}
  2357. diffStyle={layout.review.diffStyle()}
  2358. onDiffStyleChange={layout.review.setDiffStyle}
  2359. onScrollRef={setReviewScroll}
  2360. onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
  2361. comments={comments.all()}
  2362. focusedComment={comments.focus()}
  2363. onFocusedCommentChange={comments.setFocus}
  2364. onViewFile={(path) => {
  2365. const value = file.tab(path)
  2366. tabs().open(value)
  2367. file.load(path)
  2368. }}
  2369. />
  2370. </Show>
  2371. </Match>
  2372. <Match when={true}>
  2373. <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
  2374. <Mark class="w-14 opacity-10" />
  2375. <div class="text-14-regular text-text-weak max-w-56">
  2376. {language.t("session.review.empty")}
  2377. </div>
  2378. </div>
  2379. </Match>
  2380. </Switch>
  2381. </div>
  2382. </div>
  2383. </Show>
  2384. </div>
  2385. <Show when={layout.fileTree.opened()}>
  2386. <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
  2387. <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
  2388. <Tabs
  2389. variant="pill"
  2390. value={fileTreeTab()}
  2391. onChange={setFileTreeTabValue}
  2392. class="h-full"
  2393. data-scope="filetree"
  2394. >
  2395. <Tabs.List>
  2396. <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
  2397. {reviewCount()}{" "}
  2398. {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
  2399. </Tabs.Trigger>
  2400. <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
  2401. {language.t("session.files.all")}
  2402. </Tabs.Trigger>
  2403. </Tabs.List>
  2404. <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
  2405. <Switch>
  2406. <Match when={hasReview()}>
  2407. <Show
  2408. when={diffsReady()}
  2409. fallback={
  2410. <div class="px-2 py-2 text-12-regular text-text-weak">
  2411. {language.t("common.loading")}
  2412. {language.t("common.loading.ellipsis")}
  2413. </div>
  2414. }
  2415. >
  2416. <FileTree
  2417. path=""
  2418. allowed={diffFiles()}
  2419. draggable={false}
  2420. tooltip={false}
  2421. onFileClick={(node) => focusReviewDiff(node.path)}
  2422. />
  2423. </Show>
  2424. </Match>
  2425. <Match when={true}>
  2426. <div class="mt-8 text-center text-12-regular text-text-weak">
  2427. {language.t("session.review.noChanges")}
  2428. </div>
  2429. </Match>
  2430. </Switch>
  2431. </Tabs.Content>
  2432. <Tabs.Content value="all" class="bg-background-base px-3 py-0">
  2433. <FileTree
  2434. path=""
  2435. modified={diffFiles()}
  2436. tooltip={false}
  2437. onFileClick={(node) => openTab(file.tab(node.path))}
  2438. />
  2439. </Tabs.Content>
  2440. </Tabs>
  2441. </div>
  2442. <ResizeHandle
  2443. direction="horizontal"
  2444. edge="start"
  2445. size={layout.fileTree.width()}
  2446. min={200}
  2447. max={480}
  2448. collapseThreshold={160}
  2449. onResize={layout.fileTree.resize}
  2450. onCollapse={layout.fileTree.close}
  2451. />
  2452. </div>
  2453. </Show>
  2454. </aside>
  2455. </Show>
  2456. </div>
  2457. <Show when={isDesktop() && view().terminal.opened()}>
  2458. <div
  2459. id="terminal-panel"
  2460. role="region"
  2461. aria-label={language.t("terminal.title")}
  2462. class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
  2463. style={{ height: `${layout.terminal.height()}px` }}
  2464. >
  2465. <ResizeHandle
  2466. direction="vertical"
  2467. size={layout.terminal.height()}
  2468. min={100}
  2469. max={window.innerHeight * 0.6}
  2470. collapseThreshold={50}
  2471. onResize={layout.terminal.resize}
  2472. onCollapse={view().terminal.close}
  2473. />
  2474. <Show
  2475. when={terminal.ready()}
  2476. fallback={
  2477. <div class="flex flex-col h-full pointer-events-none">
  2478. <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
  2479. <For each={handoff.terminals}>
  2480. {(title) => (
  2481. <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
  2482. {title}
  2483. </div>
  2484. )}
  2485. </For>
  2486. <div class="flex-1" />
  2487. <div class="text-text-weak pr-2">
  2488. {language.t("common.loading")}
  2489. {language.t("common.loading.ellipsis")}
  2490. </div>
  2491. </div>
  2492. <div class="flex-1 flex items-center justify-center text-text-weak">
  2493. {language.t("terminal.loading")}
  2494. </div>
  2495. </div>
  2496. }
  2497. >
  2498. <DragDropProvider
  2499. onDragStart={handleTerminalDragStart}
  2500. onDragEnd={handleTerminalDragEnd}
  2501. onDragOver={handleTerminalDragOver}
  2502. collisionDetector={closestCenter}
  2503. >
  2504. <DragDropSensors />
  2505. <ConstrainDragYAxis />
  2506. <div class="flex flex-col h-full">
  2507. <Tabs
  2508. variant="alt"
  2509. value={terminal.active()}
  2510. onChange={(id) => {
  2511. // Only switch tabs if not in the middle of starting edit mode
  2512. terminal.open(id)
  2513. }}
  2514. class="!h-auto !flex-none"
  2515. >
  2516. <Tabs.List class="h-10">
  2517. <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
  2518. <For each={terminal.all()}>
  2519. {(pty) => (
  2520. <SortableTerminalTab
  2521. terminal={pty}
  2522. onClose={() => {
  2523. view().terminal.close()
  2524. setUi("autoCreated", false)
  2525. }}
  2526. />
  2527. )}
  2528. </For>
  2529. </SortableProvider>
  2530. <div class="h-full flex items-center justify-center">
  2531. <TooltipKeybind
  2532. title={language.t("command.terminal.new")}
  2533. keybind={command.keybind("terminal.new")}
  2534. class="flex items-center"
  2535. >
  2536. <IconButton
  2537. icon="plus-small"
  2538. variant="ghost"
  2539. iconSize="large"
  2540. onClick={terminal.new}
  2541. aria-label={language.t("command.terminal.new")}
  2542. />
  2543. </TooltipKeybind>
  2544. </div>
  2545. </Tabs.List>
  2546. </Tabs>
  2547. <div class="flex-1 min-h-0 relative">
  2548. <For each={terminal.all()}>
  2549. {(pty) => (
  2550. <div
  2551. id={`terminal-wrapper-${pty.id}`}
  2552. class="absolute inset-0"
  2553. style={{
  2554. display: terminal.active() === pty.id ? "block" : "none",
  2555. }}
  2556. >
  2557. <Show when={pty.id} keyed>
  2558. <Terminal
  2559. pty={pty}
  2560. onCleanup={terminal.update}
  2561. onConnectError={() => terminal.clone(pty.id)}
  2562. />
  2563. </Show>
  2564. </div>
  2565. )}
  2566. </For>
  2567. </div>
  2568. </div>
  2569. <DragOverlay>
  2570. <Show when={store.activeTerminalDraggable}>
  2571. {(draggedId) => {
  2572. const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
  2573. return (
  2574. <Show when={pty()}>
  2575. {(t) => (
  2576. <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
  2577. {(() => {
  2578. const title = t().title
  2579. const number = t().titleNumber
  2580. const match = title.match(/^Terminal (\d+)$/)
  2581. const parsed = match ? Number(match[1]) : undefined
  2582. const isDefaultTitle =
  2583. Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
  2584. if (title && !isDefaultTitle) return title
  2585. if (Number.isFinite(number) && number > 0)
  2586. return language.t("terminal.title.numbered", { number })
  2587. if (title) return title
  2588. return language.t("terminal.title")
  2589. })()}
  2590. </div>
  2591. )}
  2592. </Show>
  2593. )
  2594. }}
  2595. </Show>
  2596. </DragOverlay>
  2597. </DragDropProvider>
  2598. </Show>
  2599. </div>
  2600. </Show>
  2601. </div>
  2602. )
  2603. }