+
+
+
+
+
+
+
+
+
+
{
+ if (e.target === e.currentTarget) mobileSidebar.hide()
+ }}
+ />
+
e.stopPropagation()}
+ >
+
+
+
+
+
{props.children}
+
+
+
+ )
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
new file mode 100644
index 000000000..42e43232a
--- /dev/null
+++ b/packages/app/src/pages/session.tsx
@@ -0,0 +1,927 @@
+import {
+ For,
+ onCleanup,
+ onMount,
+ Show,
+ Match,
+ Switch,
+ createResource,
+ createMemo,
+ createEffect,
+ on,
+ createRenderEffect,
+ batch,
+} from "solid-js"
+
+import { Dynamic } from "solid-js/web"
+import { useLocal, type LocalFile } from "@/context/local"
+import { createStore } from "solid-js/store"
+import { PromptInput } from "@/components/prompt-input"
+import { DateTime } from "luxon"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } 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 { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
+import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ createSortable,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import type { JSX } from "solid-js"
+import { useSync } from "@/context/sync"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLayout } from "@/context/layout"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { Terminal } from "@/components/terminal"
+import { checksum } from "@opencode-ai/util/encode"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { useCommand } from "@/context/command"
+import { useNavigate, useParams } from "@solidjs/router"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+import { useSDK } from "@/context/sdk"
+import { usePrompt } from "@/context/prompt"
+import { extractPromptFromParts } from "@/utils/prompt"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+
+export default function Page() {
+ const layout = useLayout()
+ const local = useLocal()
+ const sync = useSync()
+ const terminal = useTerminal()
+ const dialog = useDialog()
+ const codeComponent = useCodeComponent()
+ const command = useCommand()
+ const params = useParams()
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const prompt = usePrompt()
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const revertMessageID = createMemo(() => info()?.revert?.messageID)
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+ const userMessages = createMemo(() =>
+ messages()
+ .filter((m) => m.role === "user")
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ )
+ const visibleUserMessages = createMemo(() => {
+ const revert = revertMessageID()
+ if (!revert) return userMessages()
+ return userMessages().filter((m) => m.id < revert)
+ })
+ const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
+
+ const [store, setStore] = createStore({
+ clickTimer: undefined as number | undefined,
+ activeDraggable: undefined as string | undefined,
+ activeTerminalDraggable: undefined as string | undefined,
+ userInteracted: false,
+ stepsExpanded: true,
+ mobileStepsExpanded: {} as Record
,
+ messageId: undefined as string | undefined,
+ })
+
+ const activeMessage = createMemo(() => {
+ if (!store.messageId) return lastUserMessage()
+ // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
+ 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
+
+ let targetIndex: number
+ if (currentIndex === -1) {
+ targetIndex = offset > 0 ? 0 : msgs.length - 1
+ } else {
+ targetIndex = currentIndex + offset
+ }
+
+ if (targetIndex < 0 || targetIndex >= msgs.length) return
+
+ setActiveMessage(msgs[targetIndex])
+ }
+
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+
+ let inputRef!: HTMLDivElement
+
+ createEffect(() => {
+ if (!params.id) return
+ sync.session.sync(params.id)
+ })
+
+ createEffect(() => {
+ if (layout.terminal.opened()) {
+ if (terminal.all().length === 0) {
+ terminal.new()
+ }
+ }
+ })
+
+ createEffect(
+ on(
+ () => visibleUserMessages().at(-1)?.id,
+ (lastId, prevLastId) => {
+ if (lastId && prevLastId && lastId > prevLastId) {
+ setStore("messageId", undefined)
+ }
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ params.id
+ const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
+ batch(() => {
+ setStore("userInteracted", false)
+ setStore("stepsExpanded", status.type !== "idle")
+ })
+ })
+
+ const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
+ const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
+
+ createRenderEffect((prev) => {
+ const isWorking = working()
+ if (!prev && isWorking) {
+ setStore("stepsExpanded", true)
+ }
+ if (prev && !isWorking && !store.userInteracted) {
+ setStore("stepsExpanded", false)
+ }
+ return isWorking
+ }, working())
+
+ command.register(() => [
+ {
+ id: "session.new",
+ title: "New session",
+ description: "Create a new session",
+ category: "Session",
+ keybind: "mod+shift+s",
+ slash: "new",
+ onSelect: () => navigate(`/${params.dir}/session`),
+ },
+ {
+ id: "file.open",
+ title: "Open file",
+ description: "Search and open a file",
+ category: "File",
+ keybind: "mod+p",
+ slash: "open",
+ onSelect: () => dialog.show(() => ),
+ },
+ // {
+ // id: "theme.toggle",
+ // title: "Toggle theme",
+ // description: "Switch between themes",
+ // category: "View",
+ // keybind: "ctrl+t",
+ // slash: "theme",
+ // onSelect: () => {
+ // const currentTheme = localStorage.getItem("theme") ?? "oc-1"
+ // const themes = ["oc-1", "oc-2-paper"]
+ // const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
+ // localStorage.setItem("theme", nextTheme)
+ // document.documentElement.setAttribute("data-theme", nextTheme)
+ // },
+ // },
+ {
+ id: "terminal.toggle",
+ title: "Toggle terminal",
+ description: "Show or hide the terminal",
+ category: "View",
+ keybind: "ctrl+`",
+ slash: "terminal",
+ onSelect: () => layout.terminal.toggle(),
+ },
+ {
+ id: "review.toggle",
+ title: "Toggle review",
+ description: "Show or hide the review panel",
+ category: "View",
+ keybind: "mod+b",
+ slash: "review",
+ onSelect: () => layout.review.toggle(),
+ },
+ {
+ id: "terminal.new",
+ title: "New terminal",
+ description: "Create a new terminal tab",
+ category: "Terminal",
+ keybind: "ctrl+shift+`",
+ onSelect: () => terminal.new(),
+ },
+ {
+ id: "steps.toggle",
+ title: "Toggle steps",
+ description: "Show or hide the steps",
+ category: "View",
+ keybind: "mod+e",
+ slash: "steps",
+ disabled: !params.id,
+ onSelect: () => setStore("stepsExpanded", (x) => !x),
+ },
+ {
+ 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: "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: "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 = userMessages().findLast((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)
+ prompt.set(restored)
+ }
+ // Navigate to the message before the reverted one (which will be the new last visible message)
+ const priorMessage = userMessages().findLast((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 = userMessages().findLast((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 = userMessages().findLast((x) => x.id < nextMessage.id)
+ setActiveMessage(priorMsg)
+ },
+ },
+ ])
+
+ 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)$/.test(activeElement.tagName) || activeElement.isContentEditable
+ if (isProtected || isInput) return
+ }
+ if (dialog.active) return
+
+ if (activeElement === inputRef) {
+ if (event.key === "Escape") inputRef?.blur()
+ return
+ }
+
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
+ inputRef?.focus()
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKeyDown)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKeyDown)
+ })
+
+ const resetClickTimer = () => {
+ if (!store.clickTimer) return
+ clearTimeout(store.clickTimer)
+ setStore("clickTimer", undefined)
+ }
+
+ const startClickTimer = () => {
+ const newClickTimer = setTimeout(() => {
+ setStore("clickTimer", undefined)
+ }, 300)
+ setStore("clickTimer", newClickTimer as unknown as number)
+ }
+
+ const handleTabClick = async (tab: string) => {
+ if (store.clickTimer) {
+ resetClickTimer()
+ } else {
+ if (tab.startsWith("file://")) {
+ local.file.open(tab.replace("file://", ""))
+ }
+ startClickTimer()
+ }
+ }
+
+ 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 SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
+ const sortable = createSortable(props.terminal.id)
+ return (
+ // @ts-ignore
+
+
+ 1 && (
+ terminal.close(props.terminal.id)} />
+ )
+ }
+ >
+ {props.terminal.title}
+
+
+
+ )
+ }
+
+ const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
+ return (
+
+
+
+ {props.file.name}
+
+
+
+
+ M
+
+
+ A
+
+
+ D
+
+
+
+
+ )
+ }
+
+ const SortableTab = (props: {
+ tab: string
+ onTabClick: (tab: string) => void
+ onTabClose: (tab: string) => void
+ }): JSX.Element => {
+ const sortable = createSortable(props.tab)
+ const [file] = createResource(
+ () => props.tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ // @ts-ignore
+
+
+
+ props.onTabClose(props.tab)} />
+
+ }
+ hideCloseButton
+ onClick={() => props.onTabClick(props.tab)}
+ >
+
+ {(f) => }
+
+
+
+
+ )
+ }
+
+ const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
+
+ const mobileWorking = createMemo(() => status().type !== "idle")
+ const mobileAutoScroll = createAutoScroll({
+ working: mobileWorking,
+ onUserInteracted: () => setStore("userInteracted", true),
+ })
+
+ const MobileTurns = () => (
+
+ )
+
+ const NewSessionView = () => (
+
+
New session
+
+
+
+ {getDirectory(sync.data.path.directory)}
+ {getFilename(sync.data.path.directory)}
+
+
+
+ {(project) => (
+
+
+
+ Last modified
+
+ {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+
+
+
+ )}
+
+
+ )
+
+ const DesktopSessionContent = () => (
+
+
+
+
+
+ setStore("stepsExpanded", (x) => !x)}
+ onUserInteracted={() => setStore("userInteracted", true)}
+ classes={{
+ root: "pb-20 flex-1 min-w-0",
+ content: "pb-20",
+ container:
+ "w-full " +
+ (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
+ }}
+ />
+
+
+
+
+
+
+
+ )
+
+ return (
+
+
+
+
+
+
+
+
+ 0}>
+
+
+
+ Session
+
+
+ {diffs().length} Files Changed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Review
+
+
+ {info()?.summary?.files ?? 0}
+
+
+
+
+
+
+
+
+ {(tab) => }
+
+
+
+
+ Open file
+ {command.keybind("file.open")}
+
+ }
+ class="flex items-center"
+ >
+ dialog.show(() => )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+
+
+ {(f) => (
+
+ )}
+
+
+
+ )
+ }}
+
+
+
+
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+ {(f) => }
+
+ )
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ t.id)}>
+ {(pty) => }
+
+
+
+ New terminal
+ {command.keybind("terminal.new")}
+
+ }
+ class="flex items-center"
+ >
+
+
+
+
+
+ {(pty) => (
+
+ terminal.clone(pty.id)} />
+
+ )}
+
+
+
+
+ {(draggedId) => {
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+
+ {(t) => (
+
+ {t().title}
+
+ )}
+
+ )
+ }}
+
+
+
+
+
+
+ )
+}
--
cgit v1.2.3