import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } 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, produce } from "solid-js/store" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Dialog } from "@opencode-ai/ui/dialog" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { Select } from "@opencode-ai/ui/select" import { useCodeComponent } from "@opencode-ai/ui/context/code" import { createAutoScroll } from "@opencode-ai/ui/hooks" 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 { checksum, base64Encode } 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 { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { useComments } from "@/context/comments" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" import { createScrollSpy } from "@/pages/session/scroll-spy" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { SessionReviewTab, StickyAddButton, type DiffStyle, type SessionReviewTabProps, } from "@/pages/session/review-tab" import { TerminalPanel } from "@/pages/session/terminal-panel" import { terminalTabLabel } from "@/pages/session/terminal-label" import { MessageTimeline } from "@/pages/session/message-timeline" import { useSessionCommands } from "@/pages/session/use-session-commands" import { SessionPromptDock } from "@/pages/session/session-prompt-dock" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" type HandoffSession = { prompt: string files: Record } const HANDOFF_MAX = 40 const handoff = { session: new Map(), terminal: new Map(), } const touch = (map: Map, key: K, value: V) => { map.delete(key) map.set(key, value) while (map.size > HANDOFF_MAX) { const first = map.keys().next().value if (first === undefined) return map.delete(first) } } const setSessionHandoff = (key: string, patch: Partial) => { const prev = handoff.session.get(key) ?? { prompt: "", files: {} } touch(handoff.session, key, { ...prev, ...patch }) } 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 permRequest = createMemo(() => { const sessionID = params.id if (!sessionID) return return sync.data.permission[sessionID]?.[0] }) const questionRequest = createMemo(() => { const sessionID = params.id if (!sessionID) return return sync.data.question[sessionID]?.[0] }) const blocked = createMemo(() => !!permRequest() || !!questionRequest()) const [ui, setUi] = createStore({ responding: false, pendingMessage: undefined as string | undefined, scrollGesture: 0, autoCreated: false, scroll: { overflow: false, bottom: true, }, }) createEffect( on( () => permRequest()?.id, () => setUi("responding", false), { defer: true }, ), ) const decide = (response: "once" | "always" | "reject") => { const perm = permRequest() if (!perm) return if (ui.responding) return setUi("responding", 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(() => setUi("responding", false)) } const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const workspaceKey = createMemo(() => params.dir ?? "") const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) createEffect( on( () => params.id, (id, prev) => { if (!id) return if (prev) return const pending = layout.handoff.tabs() if (!pending) return if (Date.now() - pending.at > 60_000) { layout.handoff.clearTabs() return } if (pending.id !== id) return layout.handoff.clearTabs() if (pending.dir !== (params.dir ?? "")) return const from = workspaceTabs().tabs() if (from.all.length === 0 && !from.active) return const current = tabs().tabs() if (current.all.length > 0 || current.active) return const all = normalizeTabs(from.all) const active = from.active ? normalizeTab(from.active) : undefined tabs().setAll(all) tabs().setActive(active && all.includes(active) ? active : all[0]) workspaceTabs().setAll([]) workspaceTabs().setActive(undefined) }, { defer: true }, ), ) 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)") const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" if (desktopReviewOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopSidePanelOpen()) 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 openReviewPanel = () => { if (!view().reviewPanel.opened()) view().reviewPanel.open() } const openTab = (value: string) => { const next = normalizeTab(value) tabs().open(next) const path = file.pathFromTab(next) if (!path) return file.load(path) openReviewPanel() } 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 diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) 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 [title, setTitle] = createStore({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false, }) let titleRef: HTMLInputElement | undefined const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message } if (err instanceof Error) return err.message return language.t("common.requestFailed") } createEffect( on( sessionKey, () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), { defer: true }, ), ) const openTitleEditor = () => { if (!params.id) return setTitle({ editing: true, draft: info()?.title ?? "" }) requestAnimationFrame(() => { titleRef?.focus() titleRef?.select() }) } const closeTitleEditor = () => { if (title.saving) return setTitle({ editing: false, saving: false }) } const saveTitleEditor = async () => { const sessionID = params.id if (!sessionID) return if (title.saving) return const next = title.draft.trim() if (!next || next === (info()?.title ?? "")) { setTitle({ editing: false, saving: false }) return } setTitle("saving", true) await sdk.client.session .update({ sessionID, title: next }) .then(() => { sync.set( produce((draft) => { const index = draft.session.findIndex((s) => s.id === sessionID) if (index !== -1) draft.session[index].title = next }), ) setTitle({ editing: false, saving: false }) }) .catch((err) => { setTitle("saving", false) showToast({ title: language.t("common.requestFailed"), description: errorMessage(err), }) }) } async function archiveSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return const sessions = sync.data.session ?? [] const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk.client.session .update({ sessionID, time: { archived: Date.now() } }) .then(() => { sync.set( produce((draft) => { const index = draft.session.findIndex((s) => s.id === sessionID) if (index !== -1) draft.session.splice(index, 1) }), ) if (params.id !== sessionID) return if (session.parentID) { navigate(`/${params.dir}/session/${session.parentID}`) return } if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) return } navigate(`/${params.dir}/session`) }) .catch((err) => { showToast({ title: language.t("common.requestFailed"), description: errorMessage(err), }) }) } async function deleteSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return false const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk.client.session .delete({ sessionID }) .then((x) => x.data) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(err), }) return false }) if (!result) return false sync.set( produce((draft) => { const removed = new Set([sessionID]) const byParent = new Map() for (const item of draft.session) { const parentID = item.parentID if (!parentID) continue const existing = byParent.get(parentID) if (existing) { existing.push(item.id) continue } byParent.set(parentID, [item.id]) } const stack = [sessionID] while (stack.length) { const parentID = stack.pop() if (!parentID) continue const children = byParent.get(parentID) if (!children) continue for (const child of children) { if (removed.has(child)) continue removed.add(child) stack.push(child) } } draft.session = draft.session.filter((s) => !removed.has(s.id)) }), ) if (params.id !== sessionID) return true if (session.parentID) { navigate(`/${params.dir}/session/${session.parentID}`) return true } if (nextSession) { navigate(`/${params.dir}/session/${nextSession.id}`) return true } navigate(`/${params.dir}/session`) return true } function DialogDeleteSession(props: { sessionID: string }) { const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) const handleDelete = async () => { await deleteSession(props.sessionID) dialog.close() } return (
{language.t("session.delete.confirm", { name: title() })}
) } 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" | "changes", changes: "session" as "session" | "turn", newSessionWorktree: "main", promptHeight: 0, }) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) 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 if (targetIndex === msgs.length - 1) { resumeScroll() return } autoScroll.pause() scrollToMessage(msgs[targetIndex], "auto") } const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b if (a === b) return a return "mix" as const } const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) const parts = file.split("/") for (const [idx] of parts.slice(0, -1).entries()) { const dir = parts.slice(0, idx + 1).join("/") if (!dir) continue out.set(dir, merge(out.get(dir), kind)) } } return out }) const emptyDiffFiles: string[] = [] const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same }) 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 let content: HTMLDivElement | undefined 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 setUi("scrollGesture", Date.now()) } const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { sdk.directory const id = params.id if (!id) return sync.session.sync(id) }) createEffect(() => { if (!view().terminal.opened()) { setUi("autoCreated", false) return } if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return terminal.new() setUi("autoCreated", 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() } focusTerminalById(activeId) }, ), ) 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( sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) setStore("changes", "session") setUi("autoCreated", false) }, { defer: true }, ), ) createEffect( on( () => params.dir, (dir) => { if (!dir) return setStore("newSessionWorktree", "main") }, { 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, }) } 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)) { if (blocked()) return 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(() => { focusTerminalById(activeId) }, 0) } const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() .all() .filter((tab) => tab !== "context" && tab !== "review"), ) const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened()) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const [tree, setTree] = createStore({ reviewScroll: undefined as HTMLDivElement | undefined, pendingDiff: undefined as string | undefined, activeDiff: undefined as string | undefined, }) createEffect( on( sessionKey, () => { setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) }, { defer: true }, ), ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return setFileTreeTab("all") } const focusInput = () => inputRef?.focus() useSessionCommands({ command, dialog, file, language, local, permission, prompt, sdk, sync, terminal, layout, params, navigate, tabs, view, info, status, userMessages, visibleUserMessages, activeMessage, showAllFiles, navigateMessageByOffset, setExpanded: (id, fn) => setStore("expanded", id, fn), setActiveMessage, addSelectionToContext, focusInput, }) const openReviewFile = createOpenReviewFile({ showAllFiles, tabForPath: file.tab, openTab: tabs().open, loadFile: file.load, }) const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] const changesTitle = () => (