import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { BasicTool } from "@opencode-ai/ui/basic-tool" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { useComments, type LineComment } from "@/context/comments" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, SortableTerminalTab, NewSessionView, } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" type DiffStyle = "unified" | "split" const handoff = { prompt: "", terminals: [] as string[], files: {} as Record, } interface SessionReviewTabProps { diffs: () => FileDiff[] view: () => ReturnType["view"]> diffStyle: DiffStyle onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void comments?: LineComment[] focusedComment?: { file: string; id: string } | null onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void onScrollRef?: (el: HTMLDivElement) => void classes?: { root?: string header?: string container?: string } } function SessionReviewTab(props: SessionReviewTabProps) { let scroll: HTMLDivElement | undefined let frame: number | undefined let pending: { x: number; y: number } | undefined const sdk = useSDK() const readFile = async (path: string) => { return sdk.client.file .read({ path }) .then((x) => x.data) .catch(() => undefined) } const restoreScroll = () => { const el = scroll if (!el) return const s = props.view().scroll("review") if (!s) return if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollLeft !== s.x) el.scrollLeft = s.x } const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { pending = { x: event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop, } if (frame !== undefined) return frame = requestAnimationFrame(() => { frame = undefined const next = pending pending = undefined if (!next) return props.view().setScroll("review", next) }) } createEffect( on( () => props.diffs().length, () => { requestAnimationFrame(restoreScroll) }, { defer: true }, ), ) onCleanup(() => { if (frame === undefined) return cancelAnimationFrame(frame) }) return ( { scroll = el props.onScrollRef?.(el) restoreScroll() }} onScroll={handleScroll} onDiffRendered={() => requestAnimationFrame(restoreScroll)} open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ root: props.classes?.root ?? "pb-40", header: props.classes?.header ?? "px-6", container: props.classes?.container ?? "px-6", }} diffs={props.diffs()} diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} onViewFile={props.onViewFile} readFile={readFile} onLineComment={props.onLineComment} comments={props.comments} focusedComment={props.focusedComment} onFocusedCommentChange={props.onFocusedCommentChange} /> ) } export default function Page() { const layout = useLayout() const local = useLocal() const file = useFile() const sync = useSync() const terminal = useTerminal() const dialog = useDialog() const codeComponent = useCodeComponent() const command = useCommand() const language = useLanguage() const params = useParams() const navigate = useNavigate() const sdk = useSDK() const prompt = usePrompt() const comments = useComments() const permission = usePermission() const request = createMemo(() => { const sessionID = params.id if (!sessionID) return const next = sync.data.permission[sessionID]?.[0] if (!next) return if (next.tool) return return next }) const [responding, setResponding] = createSignal(false) createEffect( on( () => request()?.id, () => setResponding(false), { defer: true }, ), ) const decide = (response: "once" | "always" | "reject") => { const perm = request() if (!perm) return if (responding()) return setResponding(true) sdk.client.permission .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) .finally(() => setResponding(false)) } const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) if (import.meta.env.DEV) { createEffect( on( () => [params.dir, params.id] as const, ([dir, id], prev) => { if (!id) return navParams({ dir, from: prev?.[1], to: id }) }, ), ) createEffect(() => { const id = params.id if (!id) return if (!prompt.ready()) return navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" }) }) createEffect(() => { const id = params.id if (!id) return if (!terminal.ready()) return navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" }) }) createEffect(() => { const id = params.id if (!id) return if (!file.ready()) return navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" }) }) createEffect(() => { const id = params.id if (!id) return if (sync.data.message[id] === undefined) return navMark({ dir: params.dir, to: id, name: "session:data-ready" }) }) } const isDesktop = createMediaQuery("(min-width: 768px)") function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab return file.tab(tab) } function normalizeTabs(list: string[]) { const seen = new Set() const next: string[] = [] for (const item of list) { const value = normalizeTab(item) if (seen.has(value)) continue seen.add(value) next.push(value) } return next } const openTab = (value: string) => { const next = normalizeTab(value) tabs().open(next) const path = file.pathFromTab(next) if (path) file.load(path) } createEffect(() => { const active = tabs().active() if (!active) return const path = file.pathFromTab(active) if (path) file.load(path) }) createEffect(() => { const current = tabs().all() if (current.length === 0) return const next = normalizeTabs(current) if (same(current, next)) return tabs().setAll(next) const active = tabs().active() if (!active) return if (!active.startsWith("file://")) return const normalized = normalizeTab(active) if (active === normalized) return tabs().setActive(normalized) }) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const reviewCount = createMemo(() => info()?.summary?.files ?? 0) const hasReview = createMemo(() => reviewCount() > 0) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { const id = params.id if (!id) return true return sync.data.message[id] !== undefined }) const historyMore = createMemo(() => { const id = params.id if (!id) return false return sync.session.history.more(id) }) const historyLoading = createMemo(() => { const id = params.id if (!id) return false return sync.session.history.loading(id) }) const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages, { equals: same }, ) const visibleUserMessages = createMemo( () => { const revert = revertMessageID() if (!revert) return userMessages() return userMessages().filter((m) => m.id < revert) }, emptyUserMessages, { equals: same, }, ) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) createEffect( on( () => lastUserMessage()?.id, () => { const msg = lastUserMessage() if (!msg) return if (msg.agent) local.agent.set(msg.agent) if (msg.model) local.model.set(msg.model) }, ), ) const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, activeTerminalDraggable: undefined as string | undefined, expanded: {} as Record, messageId: undefined as string | undefined, turnStart: 0, mobileTab: "session" as "session" | "review", newSessionWorktree: "main", promptHeight: 0, }) const renderedUserMessages = createMemo( () => { const msgs = visibleUserMessages() const start = store.turnStart if (start <= 0) return msgs if (start >= msgs.length) return emptyUserMessages return msgs.slice(start) }, emptyUserMessages, { equals: same, }, ) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory return "main" }) const activeMessage = createMemo(() => { if (!store.messageId) return lastUserMessage() const found = visibleUserMessages()?.find((m) => m.id === store.messageId) return found ?? lastUserMessage() }) const setActiveMessage = (message: UserMessage | undefined) => { setStore("messageId", message?.id) } function navigateMessageByOffset(offset: number) { const msgs = visibleUserMessages() if (msgs.length === 0) return const current = activeMessage() const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset if (targetIndex < 0 || targetIndex >= msgs.length) return scrollToMessage(msgs[targetIndex], "auto") } const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const diffsReady = createMemo(() => { const id = params.id if (!id) return true if (!hasReview()) return true return sync.data.session_diff[id] !== undefined }) const idle = { type: "idle" as const } let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined const [scrollGesture, setScrollGesture] = createSignal(0) const scrollGestureWindowMs = 250 const markScrollGesture = (target?: EventTarget | null) => { const root = scroller if (!root) return const el = target instanceof Element ? target : undefined const nested = el?.closest("[data-scrollable]") if (nested && nested !== root) return setScrollGesture(Date.now()) } const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs createEffect(() => { if (!params.id) return sync.session.sync(params.id) }) const [autoCreated, setAutoCreated] = createSignal(false) createEffect(() => { if (!view().terminal.opened()) { setAutoCreated(false) return } if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return terminal.new() setAutoCreated(true) }) createEffect( on( () => terminal.all().length, (count, prevCount) => { if (prevCount !== undefined && prevCount > 0 && count === 0) { if (view().terminal.opened()) { view().terminal.toggle() } } }, ), ) createEffect( on( () => terminal.active(), (activeId) => { if (!activeId || !view().terminal.opened()) return // Immediately remove focus if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement if (!element) return // Find and focus the ghostty textarea (the actual input element) const textarea = element.querySelector("textarea") as HTMLTextAreaElement if (textarea) { textarea.focus() return } // Fallback: focus container and dispatch pointer event element.focus() element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) }, ), ) createEffect( on( () => visibleUserMessages().at(-1)?.id, (lastId, prevLastId) => { if (lastId && prevLastId && lastId > prevLastId) { setStore("messageId", undefined) } }, { defer: true }, ), ) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) createEffect( on( () => params.id, () => { setStore("messageId", undefined) setStore("expanded", {}) }, { defer: true }, ), ) createEffect(() => { const id = lastUserMessage()?.id if (!id) return setStore("expanded", id, status().type !== "idle") }) const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined const start = Math.max(1, Math.min(selection.startLine, selection.endLine)) const end = Math.max(selection.startLine, selection.endLine) const lines = content.split("\n").slice(start - 1, end) if (lines.length === 0) return undefined return lines.slice(0, 2).join("\n") } const addSelectionToContext = (path: string, selection: FileSelection) => { const preview = selectionPreview(path, selection) prompt.context.add({ type: "file", path, selection, preview }) } const addCommentToContext = (input: { file: string selection: SelectedLineRange comment: string preview?: string origin?: "review" | "file" }) => { const selection = selectionFromLines(input.selection) const preview = input.preview ?? selectionPreview(input.file, selection) const saved = comments.add({ file: input.file, selection: input.selection, comment: input.comment, }) prompt.context.add({ type: "file", path: input.file, selection, comment: input.comment, commentID: saved.id, commentOrigin: input.origin, preview, }) } command.register(() => [ { id: "session.new", title: "New session", category: "Session", keybind: "mod+shift+s", slash: "new", onSelect: () => navigate(`/${params.dir}/session`), }, { id: "file.open", title: "Open file", description: "Search files and commands", category: "File", keybind: "mod+p", slash: "open", onSelect: () => dialog.show(() => ), }, { id: "context.addSelection", title: "Add selection to context", description: "Add selected lines from the current file", category: "Context", keybind: "mod+shift+l", disabled: (() => { const active = tabs().active() if (!active) return true const path = file.pathFromTab(active) if (!path) return true return file.selectedLines(path) == null })(), onSelect: () => { const active = tabs().active() if (!active) return const path = file.pathFromTab(active) if (!path) return const range = file.selectedLines(path) if (!range) { showToast({ title: "No line selection", description: "Select a line range in a file tab first.", }) return } addSelectionToContext(path, selectionFromLines(range)) }, }, { id: "terminal.toggle", title: "Toggle terminal", description: "", category: "View", keybind: "ctrl+`", slash: "terminal", onSelect: () => view().terminal.toggle(), }, { id: "review.toggle", title: "Toggle review", description: "", category: "View", keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }, { id: "terminal.new", title: language.t("command.terminal.new"), description: language.t("command.terminal.new.description"), category: language.t("command.category.terminal"), keybind: "ctrl+alt+t", onSelect: () => { if (terminal.all().length > 0) terminal.new() view().terminal.open() }, }, { id: "steps.toggle", title: "Toggle steps", description: "Show or hide steps for the current message", category: "View", keybind: "mod+e", slash: "steps", disabled: !params.id, onSelect: () => { const msg = activeMessage() if (!msg) return setStore("expanded", msg.id, (open: boolean | undefined) => !open) }, }, { id: "message.previous", title: "Previous message", description: "Go to the previous user message", category: "Session", keybind: "mod+arrowup", disabled: !params.id, onSelect: () => navigateMessageByOffset(-1), }, { id: "message.next", title: "Next message", description: "Go to the next user message", category: "Session", keybind: "mod+arrowdown", disabled: !params.id, onSelect: () => navigateMessageByOffset(1), }, { id: "model.choose", title: "Choose model", description: "Select a different model", category: "Model", keybind: "mod+'", slash: "model", onSelect: () => dialog.show(() => ), }, { id: "mcp.toggle", title: "Toggle MCPs", description: "Toggle MCPs", category: "MCP", keybind: "mod+;", slash: "mcp", onSelect: () => dialog.show(() => ), }, { id: "agent.cycle", title: "Cycle agent", description: "Switch to the next agent", category: "Agent", keybind: "mod+.", slash: "agent", onSelect: () => local.agent.move(1), }, { id: "agent.cycle.reverse", title: "Cycle agent backwards", description: "Switch to the previous agent", category: "Agent", keybind: "shift+mod+.", onSelect: () => local.agent.move(-1), }, { id: "model.variant.cycle", title: "Cycle thinking effort", description: "Switch to the next effort level", category: "Model", keybind: "shift+mod+d", onSelect: () => { local.model.variant.cycle() }, }, { id: "permissions.autoaccept", title: params.id && permission.isAutoAccepting(params.id, sdk.directory) ? "Stop auto-accepting edits" : "Auto-accept edits", category: "Permissions", keybind: "mod+shift+a", disabled: !params.id || !permission.permissionsEnabled(), onSelect: () => { const sessionID = params.id if (!sessionID) return permission.toggleAutoAccept(sessionID, sdk.directory) showToast({ title: permission.isAutoAccepting(sessionID, sdk.directory) ? "Auto-accepting edits" : "Stopped auto-accepting edits", description: permission.isAutoAccepting(sessionID, sdk.directory) ? "Edit and write permissions will be automatically approved" : "Edit and write permissions will require approval", }) }, }, { id: "session.undo", title: "Undo", description: "Undo the last message", category: "Session", slash: "undo", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { const sessionID = params.id if (!sessionID) return if (status()?.type !== "idle") { await sdk.client.session.abort({ sessionID }).catch(() => {}) } const revert = info()?.revert?.messageID // Find the last user message that's not already reverted const message = findLast(userMessages(), (x) => !revert || x.id < revert) if (!message) return await sdk.client.session.revert({ sessionID, messageID: message.id }) // Restore the prompt from the reverted message const parts = sync.data.part[message.id] if (parts) { const restored = extractPromptFromParts(parts, { directory: sdk.directory }) prompt.set(restored) } // Navigate to the message before the reverted one (which will be the new last visible message) const priorMessage = findLast(userMessages(), (x) => x.id < message.id) setActiveMessage(priorMessage) }, }, { id: "session.redo", title: "Redo", description: "Redo the last undone message", category: "Session", slash: "redo", disabled: !params.id || !info()?.revert?.messageID, onSelect: async () => { const sessionID = params.id if (!sessionID) return const revertMessageID = info()?.revert?.messageID if (!revertMessageID) return const nextMessage = userMessages().find((x) => x.id > revertMessageID) if (!nextMessage) { // Full unrevert - restore all messages and navigate to last await sdk.client.session.unrevert({ sessionID }) prompt.reset() // Navigate to the last message (the one that was at the revert point) const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) setActiveMessage(lastMsg) return } // Partial redo - move forward to next message await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) // Navigate to the message before the new revert point const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) setActiveMessage(priorMsg) }, }, { id: "session.compact", title: "Compact session", description: "Summarize the session to reduce context size", category: "Session", slash: "compact", disabled: !params.id || visibleUserMessages().length === 0, onSelect: async () => { const sessionID = params.id if (!sessionID) return const model = local.model.current() if (!model) { showToast({ title: "No model selected", description: "Connect a provider to summarize this session", }) return } await sdk.client.session.summarize({ sessionID, modelID: model.id, providerID: model.provider.id, }) }, }, { id: "session.fork", title: "Fork from message", description: "Create a new session from a previous message", category: "Session", slash: "fork", disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => ), }, ...(sync.data.config.share !== "disabled" ? [ { id: "session.share", title: "Share session", description: "Share this session and copy the URL to clipboard", category: "Session", slash: "share", disabled: !params.id || !!info()?.share?.url, onSelect: async () => { if (!params.id) return await sdk.client.session .share({ sessionID: params.id }) .then((res) => { navigator.clipboard.writeText(res.data!.share!.url).catch(() => showToast({ title: "Failed to copy URL to clipboard", variant: "error", }), ) }) .then(() => showToast({ title: "Session shared", description: "Share URL copied to clipboard!", variant: "success", }), ) .catch(() => showToast({ title: "Failed to share session", description: "An error occurred while sharing the session", variant: "error", }), ) }, }, { id: "session.unshare", title: "Unshare session", description: "Stop sharing this session", category: "Session", slash: "unshare", disabled: !params.id || !info()?.share?.url, onSelect: async () => { if (!params.id) return await sdk.client.session .unshare({ sessionID: params.id }) .then(() => showToast({ title: "Session unshared", description: "Session unshared successfully!", variant: "success", }), ) .catch(() => showToast({ title: "Failed to unshare session", description: "An error occurred while unsharing the session", variant: "error", }), ) }, }, ] : []), ]) const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | undefined if (activeElement) { const isProtected = activeElement.closest("[data-prevent-autofocus]") const isInput = /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(activeElement.tagName) || activeElement.isContentEditable if (isProtected || isInput) return } if (dialog.active) return if (activeElement === inputRef) { if (event.key === "Escape") inputRef?.blur() return } // Don't autofocus chat if terminal panel is open if (view().terminal.opened()) return // Only treat explicit scroll keys as potential "user scroll" gestures. if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { markScrollGesture() return } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } } const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return setStore("activeDraggable", id) } const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { const currentTabs = tabs().all() const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { tabs().move(draggable.id.toString(), toIndex) } } } const handleDragEnd = () => { setStore("activeDraggable", undefined) } const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return setStore("activeTerminalDraggable", id) } const handleTerminalDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { const terminals = terminal.all() const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { terminal.move(draggable.id.toString(), toIndex) } } } const handleTerminalDragEnd = () => { setStore("activeTerminalDraggable", undefined) const activeId = terminal.active() if (!activeId) return setTimeout(() => { const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement if (!element) return // Find and focus the ghostty textarea (the actual input element) const textarea = element.querySelector("textarea") as HTMLTextAreaElement if (textarea) { textarea.focus() return } // Fallback: focus container and dispatch pointer event element.focus() element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) }, 0) } const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() .all() .filter((tab) => tab !== "context"), ) const mobileReview = createMemo(() => !isDesktop() && view().reviewPanel.opened() && store.mobileTab === "review") const showTabs = createMemo(() => view().reviewPanel.opened()) const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes") const [reviewScroll, setReviewScroll] = createSignal(undefined) const [pendingDiff, setPendingDiff] = createSignal(undefined) createEffect(() => { if (!layout.fileTree.opened()) return setFileTreeTab("changes") }) const setFileTreeTabValue = (value: string) => { if (value !== "changes" && value !== "all") return setFileTreeTab(value) } const reviewDiffId = (path: string) => { const sum = checksum(path) if (!sum) return return `session-review-diff-${sum}` } const reviewDiffTop = (path: string) => { const root = reviewScroll() if (!root) return const id = reviewDiffId(path) if (!id) return const el = document.getElementById(id) if (!(el instanceof HTMLElement)) return if (!root.contains(el)) return const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() return a.top - b.top + root.scrollTop } const scrollToReviewDiff = (path: string) => { const root = reviewScroll() if (!root) return false const top = reviewDiffTop(path) if (top === undefined) return false view().setScroll("review", { x: root.scrollLeft, y: top }) root.scrollTo({ top, behavior: "auto" }) return true } const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) setPendingDiff(path) } createEffect(() => { const pending = pendingDiff() if (!pending) return if (!reviewScroll()) return if (!diffsReady()) return const attempt = (count: number) => { if (pendingDiff() !== pending) return if (count > 60) { setPendingDiff(undefined) return } const root = reviewScroll() if (!root) { requestAnimationFrame(() => attempt(count + 1)) return } if (!scrollToReviewDiff(pending)) { requestAnimationFrame(() => attempt(count + 1)) return } const top = reviewDiffTop(pending) if (top === undefined) { requestAnimationFrame(() => attempt(count + 1)) return } if (Math.abs(root.scrollTop - top) <= 1) { setPendingDiff(undefined) return } requestAnimationFrame(() => attempt(count + 1)) } requestAnimationFrame(() => attempt(0)) }) const activeTab = createMemo(() => { const active = tabs().active() if (layout.fileTree.opened() && fileTreeTab() === "all") { if (active && active !== "review" && active !== "context") return normalizeTab(active) const first = openedTabs()[0] if (first) return first return "review" } if (active) return normalizeTab(active) if (hasReview()) return "review" const first = openedTabs()[0] if (first) return first if (contextOpen()) return "context" return "review" }) createEffect(() => { if (!layout.ready()) return if (tabs().active()) return if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return tabs().setActive(activeTab()) }) createEffect(() => { if (!layout.fileTree.opened()) return if (fileTreeTab() !== "all") return const first = openedTabs()[0] if (!first) return const active = tabs().active() if (active && active !== "review" && active !== "context") return tabs().setActive(first) }) createEffect(() => { const id = params.id if (!id) return if (!hasReview()) return const wants = isDesktop() ? view().reviewPanel.opened() && (layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review") : store.mobileTab === "review" if (!wants) return if (diffsReady()) return sync.session.diff(id) }) const autoScroll = createAutoScroll({ working: () => true, overflowAnchor: "dynamic", }) const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() } // When the user returns to the bottom, treat the active message as "latest". createEffect( on( autoScroll.userScrolled, (scrolled) => { if (scrolled) return setStore("messageId", undefined) }, { defer: true }, ), ) let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) } const turnInit = 20 const turnBatch = 20 let turnHandle: number | undefined let turnIdle = false function cancelTurnBackfill() { const handle = turnHandle if (handle === undefined) return turnHandle = undefined if (turnIdle && window.cancelIdleCallback) { window.cancelIdleCallback(handle) return } clearTimeout(handle) } function scheduleTurnBackfill() { if (turnHandle !== undefined) return if (store.turnStart <= 0) return if (window.requestIdleCallback) { turnIdle = true turnHandle = window.requestIdleCallback(() => { turnHandle = undefined backfillTurns() }) return } turnIdle = false turnHandle = window.setTimeout(() => { turnHandle = undefined backfillTurns() }, 0) } function backfillTurns() { const start = store.turnStart if (start <= 0) return const next = start - turnBatch const nextStart = next > 0 ? next : 0 const el = scroller if (!el) { setStore("turnStart", nextStart) scheduleTurnBackfill() return } const beforeTop = el.scrollTop const beforeHeight = el.scrollHeight setStore("turnStart", nextStart) requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight if (delta) el.scrollTop = beforeTop + delta }) scheduleTurnBackfill() } createEffect( on( () => [params.id, messagesReady()] as const, ([id, ready]) => { cancelTurnBackfill() setStore("turnStart", 0) if (!id || !ready) return const len = visibleUserMessages().length const start = len > turnInit ? len - turnInit : 0 setStore("turnStart", start) scheduleTurnBackfill() }, { defer: true }, ), ) createResizeObserver( () => promptDock, ({ height }) => { const next = Math.ceil(height) if (next === store.promptHeight) return const el = scroller const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false setStore("promptHeight", next) if (stick && el) { requestAnimationFrame(() => { el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } }, ) const updateHash = (id: string) => { window.history.replaceState(null, "", `#${anchor(id)}`) } createEffect(() => { const sessionID = params.id if (!sessionID) return const raw = sessionStorage.getItem("opencode.pendingMessage") if (!raw) return const parts = raw.split("|") const pendingSessionID = parts[0] const messageID = parts[1] if (!pendingSessionID || !messageID) return if (pendingSessionID !== sessionID) return sessionStorage.removeItem("opencode.pendingMessage") setPendingMessage(messageID) }) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller if (!root) return false const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() const top = a.top - b.top + root.scrollTop root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { setActiveMessage(message) const msgs = visibleUserMessages() const index = msgs.findIndex((m) => m.id === message.id) if (index !== -1 && index < store.turnStart) { setStore("turnStart", index) scheduleTurnBackfill() requestAnimationFrame(() => { const el = document.getElementById(anchor(message.id)) if (!el) { requestAnimationFrame(() => { const next = document.getElementById(anchor(message.id)) if (!next) return scrollToElement(next, behavior) }) return } scrollToElement(el, behavior) }) updateHash(message.id) return } const el = document.getElementById(anchor(message.id)) if (!el) { updateHash(message.id) requestAnimationFrame(() => { const next = document.getElementById(anchor(message.id)) if (!next) return if (!scrollToElement(next, behavior)) return }) return } if (scrollToElement(el, behavior)) { updateHash(message.id) return } requestAnimationFrame(() => { const next = document.getElementById(anchor(message.id)) if (!next) return if (!scrollToElement(next, behavior)) return }) updateHash(message.id) } const applyHash = (behavior: ScrollBehavior) => { const hash = window.location.hash.slice(1) if (!hash) { autoScroll.forceScrollToBottom() return } const match = hash.match(/^message-(.+)$/) if (match) { const msg = visibleUserMessages().find((m) => m.id === match[1]) if (msg) { scrollToMessage(msg, behavior) return } // If we have a message hash but the message isn't loaded/rendered yet, // don't fall back to "bottom". We'll retry once messages arrive. return } const target = document.getElementById(hash) if (target) { scrollToElement(target, behavior) return } autoScroll.forceScrollToBottom() } const closestMessage = (node: Element | null): HTMLElement | null => { if (!node) return null const match = node.closest?.("[data-message-id]") as HTMLElement | null if (match) return match const root = node.getRootNode?.() if (root instanceof ShadowRoot) return closestMessage(root.host) return null } const getActiveMessageId = (container: HTMLDivElement) => { const rect = container.getBoundingClientRect() if (!rect.width || !rect.height) return const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2)) const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100)) const hit = document.elementFromPoint(x, y) const host = closestMessage(hit) const id = host?.dataset.messageId if (id) return id // Fallback: DOM query (handles edge hit-testing cases) const cutoff = container.scrollTop + 100 const nodes = container.querySelectorAll("[data-message-id]") let last: string | undefined for (const node of nodes) { const next = node.dataset.messageId if (!next) continue if (node.offsetTop > cutoff) break last = next } return last } const scheduleScrollSpy = (container: HTMLDivElement) => { scrollSpyTarget = container if (scrollSpyFrame !== undefined) return scrollSpyFrame = requestAnimationFrame(() => { scrollSpyFrame = undefined const target = scrollSpyTarget scrollSpyTarget = undefined if (!target) return const id = getActiveMessageId(target) if (!id) return if (id === store.messageId) return setStore("messageId", id) }) } createEffect(() => { const sessionID = params.id const ready = messagesReady() if (!sessionID || !ready) return requestAnimationFrame(() => { applyHash("auto") }) }) // Retry message navigation once the target message is actually loaded. createEffect(() => { const sessionID = params.id const ready = messagesReady() if (!sessionID || !ready) return // dependencies visibleUserMessages().length store.turnStart const targetId = pendingMessage() ?? (() => { const hash = window.location.hash.slice(1) const match = hash.match(/^message-(.+)$/) if (!match) return undefined return match[1] })() if (!targetId) return if (store.messageId === targetId) return const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return if (pendingMessage() === targetId) setPendingMessage(undefined) requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) createEffect(() => { const sessionID = params.id const ready = messagesReady() if (!sessionID || !ready) return const handler = () => requestAnimationFrame(() => applyHash("auto")) window.addEventListener("hashchange", handler) onCleanup(() => window.removeEventListener("hashchange", handler)) }) createEffect(() => { document.addEventListener("keydown", handleKeyDown) }) const previewPrompt = () => prompt .current() .map((part) => { if (part.type === "file") return `[file:${part.path}]` if (part.type === "agent") return `@${part.name}` if (part.type === "image") return `[image:${part.filename}]` return part.content }) .join("") .trim() createEffect(() => { if (!prompt.ready()) return handoff.prompt = previewPrompt() }) createEffect(() => { if (!terminal.ready()) return language.locale() const label = (pty: LocalPTY) => { const title = pty.title const number = pty.titleNumber const match = title.match(/^Terminal (\d+)$/) const parsed = match ? Number(match[1]) : undefined const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number if (title && !isDefaultTitle) return title if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) if (title) return title return language.t("terminal.title") } handoff.terminals = terminal.all().map(label) }) createEffect(() => { if (!file.ready()) return handoff.files = Object.fromEntries( tabs() .all() .flatMap((tab) => { const path = file.pathFromTab(tab) if (!path) return [] return [[path, file.selectedLines(path) ?? null] as const] }), ) }) onCleanup(() => { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) }) return (
{/* Mobile tab bar - only shown on mobile when user opened review */} setStore("mobileTab", "session")} > {language.t("session.tab.session")} setStore("mobileTab", "review")} > {language.t("session.review.filesChanged", { count: reviewCount() })} {language.t("session.tab.review")} {/* Session panel */}
{language.t("session.review.loadingChanges")}
} > addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} onFocusedCommentChange={comments.setFocus} onViewFile={(path) => { const value = file.tab(path) tabs().open(value) file.load(path) }} classes={{ root: "pb-[calc(var(--prompt-height,8rem)+32px)]", header: "px-4", container: "px-4", }} />
{language.t("session.review.empty")}
} >
markScrollGesture(e.target)} onTouchMove={(e) => markScrollGesture(e.target)} onPointerDown={(e) => { if (e.target !== e.currentTarget) return markScrollGesture(e.target) }} onScroll={(e) => { if (!hasScrollGesture()) return markScrollGesture(e.target) autoScroll.handleScroll() if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} onClick={autoScroll.handleInteraction} class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }} >
{ navigate(`/${params.dir}/session/${info()?.parentID}`) }} aria-label={language.t("common.goBack")} />

{info()?.title}

0}>
{(message) => { if (import.meta.env.DEV) { onMount(() => { const id = params.id if (!id) return navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) }) } return (
setStore("expanded", message.id, (open: boolean | undefined) => !open) } classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", container: "w-full px-4 md:px-6", }} />
) }}
{ if (value === "create") { setStore("newSessionWorktree", value) return } setStore("newSessionWorktree", "main") const target = value === "main" ? sync.project?.worktree : value if (!target) return if (target === sync.data.path.directory) return layout.projects.open(target) navigate(`/${base64Encode(target)}/session`) }} />
{/* Prompt input */}
(promptDock = el)} 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" >
{(perm) => (
0}>
{(pattern) => {pattern}}
{language.t("settings.permissions.tool.doom_loop.description")}
)}
{handoff.prompt || "Loading prompt..."}
} > { inputRef = el }} newSessionWorktree={newSessionWorktree()} onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} onSubmit={resumeScroll} />
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
{(title) => (
{title}
)}
Loading...
Loading terminal...
} >
{ // Only switch tabs if not in the middle of starting edit mode terminal.open(id) }} class="!h-auto !flex-none" > t.id)}> {(pty) => ( { view().terminal.close() setAutoCreated(false) }} /> )}
{(pty) => (
terminal.clone(pty.id)} />
)}
{(draggedId) => { const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) return ( {(t) => (
{(() => { const title = t().title const number = t().titleNumber const match = title.match(/^Terminal (\d+)$/) const parsed = match ? Number(match[1]) : undefined const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number if (title && !isDefaultTitle) return title if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) if (title) return title return language.t("terminal.title") })()}
)}
) }}
) }