From 794fe8f381c846f5241800363023d892c12cf495 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:38:50 -0600 Subject: chore: rename packages/desktop -> packages/app --- packages/app/src/pages/directory-layout.tsx | 31 + packages/app/src/pages/error.tsx | 155 +++++ packages/app/src/pages/home.tsx | 93 +++ packages/app/src/pages/layout.tsx | 904 +++++++++++++++++++++++++++ packages/app/src/pages/session.tsx | 927 ++++++++++++++++++++++++++++ 5 files changed, 2110 insertions(+) create mode 100644 packages/app/src/pages/directory-layout.tsx create mode 100644 packages/app/src/pages/error.tsx create mode 100644 packages/app/src/pages/home.tsx create mode 100644 packages/app/src/pages/layout.tsx create mode 100644 packages/app/src/pages/session.tsx (limited to 'packages/app/src/pages') diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx new file mode 100644 index 000000000..c909a373d --- /dev/null +++ b/packages/app/src/pages/directory-layout.tsx @@ -0,0 +1,31 @@ +import { createMemo, Show, type ParentProps } from "solid-js" +import { useParams } from "@solidjs/router" +import { SDKProvider } from "@/context/sdk" +import { SyncProvider, useSync } from "@/context/sync" +import { LocalProvider } from "@/context/local" +import { base64Decode } from "@opencode-ai/util/encode" +import { DataProvider } from "@opencode-ai/ui/context" +import { iife } from "@opencode-ai/util/iife" + +export default function Layout(props: ParentProps) { + const params = useParams() + const directory = createMemo(() => { + return base64Decode(params.dir!) + }) + return ( + + + + {iife(() => { + const sync = useSync() + return ( + + {props.children} + + ) + })} + + + + ) +} diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx new file mode 100644 index 000000000..9914279ad --- /dev/null +++ b/packages/app/src/pages/error.tsx @@ -0,0 +1,155 @@ +import { TextField } from "@opencode-ai/ui/text-field" +import { Logo } from "@opencode-ai/ui/logo" +import { Button } from "@opencode-ai/ui/button" +import { Component } from "solid-js" +import { usePlatform } from "@/context/platform" +import { Icon } from "@opencode-ai/ui/icon" + +export type InitError = { + name: string + data: Record +} + +function isInitError(error: unknown): error is InitError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + "data" in error && + typeof (error as InitError).data === "object" + ) +} + +function formatInitError(error: InitError): string { + const data = error.data + switch (error.name) { + case "MCPFailed": + return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.` + case "ProviderModelNotFoundError": { + const { providerID, modelID, suggestions } = data as { + providerID: string + modelID: string + suggestions?: string[] + } + return [ + `Model not found: ${providerID}/${modelID}`, + ...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), + `Check your config (opencode.json) provider/model names`, + ].join("\n") + } + case "ProviderInitError": + return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.` + case "ConfigJsonError": + return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "") + case "ConfigDirectoryTypoError": + return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.` + case "ConfigFrontmatterError": + return `Failed to parse frontmatter in ${data.path}:\n${data.message}` + case "ConfigInvalidError": { + const issues = Array.isArray(data.issues) + ? data.issues.map( + (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."), + ) + : [] + return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join( + "\n", + ) + } + case "UnknownError": + return String(data.message) + default: + return data.message ? String(data.message) : JSON.stringify(data, null, 2) + } +} + +function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string { + if (!error) return "Unknown error" + + if (isInitError(error)) { + const message = formatInitError(error) + if (depth > 0 && parentMessage === message) return "" + const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" + return indent + message + } + + if (error instanceof Error) { + const isDuplicate = depth > 0 && parentMessage === error.message + const parts: string[] = [] + const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" + + if (!isDuplicate) { + // Stack already includes error name and message, so prefer it + parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`)) + } else if (error.stack) { + // Duplicate message - only show the stack trace lines (skip message) + const trace = error.stack.split("\n").slice(1).join("\n").trim() + if (trace) { + parts.push(trace) + } + } + + if (error.cause) { + const causeResult = formatErrorChain(error.cause, depth + 1, error.message) + if (causeResult) { + parts.push(causeResult) + } + } + + return parts.join("\n\n") + } + + if (typeof error === "string") { + if (depth > 0 && parentMessage === error) return "" + const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" + return indent + error + } + + const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" + return indent + JSON.stringify(error, null, 2) +} + +function formatError(error: unknown): string { + return formatErrorChain(error, 0) +} + +interface ErrorPageProps { + error: unknown +} + +export const ErrorPage: Component = (props) => { + const platform = usePlatform() + return ( +
+
+ +
+

Something went wrong

+

An error occurred while loading the application.

+
+ + +
+ Please report this error to the OpenCode team + +
+
+
+ ) +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx new file mode 100644 index 000000000..7cd2916e8 --- /dev/null +++ b/packages/app/src/pages/home.tsx @@ -0,0 +1,93 @@ +import { useGlobalSync } from "@/context/global-sync" +import { createMemo, For, Match, Show, Switch } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Logo } from "@opencode-ai/ui/logo" +import { useLayout } from "@/context/layout" +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/util/encode" +import { Icon } from "@opencode-ai/ui/icon" +import { usePlatform } from "@/context/platform" +import { DateTime } from "luxon" + +export default function Home() { + const sync = useGlobalSync() + const layout = useLayout() + const platform = usePlatform() + const navigate = useNavigate() + const homedir = createMemo(() => sync.data.path.home) + + function openProject(directory: string) { + layout.projects.open(directory) + navigate(`/${base64Encode(directory)}`) + } + + async function chooseProject() { + const result = await platform.openDirectoryPickerDialog?.({ + title: "Open project", + multiple: true, + }) + if (Array.isArray(result)) { + for (const directory of result) { + openProject(directory) + } + } else if (result) { + openProject(result) + } + } + + return ( +
+ + + 0}> +
+
+
Recent projects
+ + + +
+
    + (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5)} + > + {(project) => ( + + )} + +
+
+
+ +
+ +
+
No recent projects
+
Get started by opening a local project
+
+
+ + + +
+ + +
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx new file mode 100644 index 000000000..489899f88 --- /dev/null +++ b/packages/app/src/pages/layout.tsx @@ -0,0 +1,904 @@ +import { + createEffect, + createMemo, + createSignal, + For, + Match, + onCleanup, + onMount, + ParentProps, + Show, + Switch, + type JSX, +} from "solid-js" +import { DateTime } from "luxon" +import { A, useNavigate, useParams } from "@solidjs/router" +import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" +import { Avatar } from "@opencode-ai/ui/avatar" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { Spinner } from "@opencode-ai/ui/spinner" +import { getFilename } from "@opencode-ai/util/path" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Session } from "@opencode-ai/sdk/v2/client" +import { usePlatform } from "@/context/platform" +import { createStore, produce } from "solid-js/store" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + createSortable, +} from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { useProviders } from "@/hooks/use-providers" +import { showToast, Toast } from "@opencode-ai/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" +import { useNotification } from "@/context/notification" +import { Binary } from "@opencode-ai/util/binary" +import { Header } from "@/components/header" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { useCommand } from "@/context/command" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" + +export default function Layout(props: ParentProps) { + const [store, setStore] = createStore({ + lastSession: {} as { [directory: string]: string }, + activeDraggable: undefined as string | undefined, + mobileSidebarOpen: false, + mobileProjectsExpanded: {} as Record, + }) + + const mobileSidebar = { + open: () => store.mobileSidebarOpen, + show: () => setStore("mobileSidebarOpen", true), + hide: () => setStore("mobileSidebarOpen", false), + toggle: () => setStore("mobileSidebarOpen", (x) => !x), + } + + const mobileProjects = { + expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, + expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), + collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false), + } + + let scrollContainerRef: HTMLDivElement | undefined + const xlQuery = window.matchMedia("(min-width: 1280px)") + const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches) + const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches) + xlQuery.addEventListener("change", handleViewportChange) + onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange)) + + const params = useParams() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const layout = useLayout() + const platform = usePlatform() + const notification = useNotification() + const navigate = useNavigate() + const providers = useProviders() + const dialog = useDialog() + const command = useCommand() + + onMount(async () => { + if (platform.checkUpdate && platform.update && platform.restart) { + const { updateAvailable, version } = await platform.checkUpdate() + if (updateAvailable) { + showToast({ + persistent: true, + icon: "download", + title: "Update available", + description: `A new version of OpenCode (${version}) is now available to install.`, + actions: [ + { + label: "Install and restart", + onClick: async () => { + await platform.update!() + await platform.restart!() + }, + }, + { + label: "Not yet", + onClick: "dismiss", + }, + ], + }) + } + } + }) + + function flattenSessions(sessions: Session[]): Session[] { + const childrenMap = new Map() + for (const session of sessions) { + if (session.parentID) { + const children = childrenMap.get(session.parentID) ?? [] + children.push(session) + childrenMap.set(session.parentID, children) + } + } + const result: Session[] = [] + function visit(session: Session) { + result.push(session) + for (const child of childrenMap.get(session.id) ?? []) { + visit(child) + } + } + for (const session of sessions) { + if (!session.parentID) visit(session) + } + return result + } + + function scrollToSession(sessionId: string) { + if (!scrollContainerRef) return + const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) + if (element) { + element.scrollIntoView({ block: "center", behavior: "smooth" }) + } + } + + function projectSessions(directory: string) { + if (!directory) return [] + const sessions = globalSync + .child(directory)[0] + .session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + return flattenSessions(sessions ?? []) + } + + const currentSessions = createMemo(() => { + if (!params.dir) return [] + const directory = base64Decode(params.dir) + return projectSessions(directory) + }) + + function navigateSessionByOffset(offset: number) { + const projects = layout.projects.list() + if (projects.length === 0) return + + const currentDirectory = params.dir ? base64Decode(params.dir) : undefined + const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1 + + if (projectIndex === -1) { + const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1] + if (targetProject) navigateToProject(targetProject.worktree) + return + } + + const sessions = currentSessions() + const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + + let targetIndex: number + if (sessionIndex === -1) { + targetIndex = offset > 0 ? 0 : sessions.length - 1 + } else { + targetIndex = sessionIndex + offset + } + + if (targetIndex >= 0 && targetIndex < sessions.length) { + const session = sessions[targetIndex] + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id)) + return + } + + const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1) + const nextProject = projects[nextProjectIndex] + if (!nextProject) return + + const nextProjectSessions = projectSessions(nextProject.worktree) + if (nextProjectSessions.length === 0) { + navigateToProject(nextProject.worktree) + return + } + + const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1] + navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`) + queueMicrotask(() => scrollToSession(targetSession.id)) + } + + async function archiveSession(session: Session) { + const [store, setStore] = globalSync.child(session.directory) + const sessions = store.session ?? [] + const index = sessions.findIndex((s) => s.id === session.id) + const nextSession = sessions[index + 1] ?? sessions[index - 1] + + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: Date.now() }, + }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + if (session.id === params.id) { + if (nextSession) { + navigate(`/${params.dir}/session/${nextSession.id}`) + } else { + navigate(`/${params.dir}/session`) + } + } + } + + command.register(() => [ + { + id: "sidebar.toggle", + title: "Toggle sidebar", + category: "View", + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), + }, + ...(platform.openDirectoryPickerDialog + ? [ + { + id: "project.open", + title: "Open project", + category: "Project", + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + ] + : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, + { + id: "session.previous", + title: "Previous session", + category: "Session", + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), + }, + { + id: "session.next", + title: "Next session", + category: "Session", + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.archive", + title: "Archive session", + category: "Session", + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + ]) + + function connectProvider() { + dialog.show(() => ) + } + + function navigateToProject(directory: string | undefined) { + if (!directory) return + const lastSession = store.lastSession[directory] + navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) + mobileSidebar.hide() + } + + function navigateToSession(session: Session | undefined) { + if (!session) return + navigate(`/${params.dir}/session/${session?.id}`) + mobileSidebar.hide() + } + + function openProject(directory: string, navigate = true) { + layout.projects.open(directory) + if (navigate) navigateToProject(directory) + } + + function closeProject(directory: string) { + const index = layout.projects.list().findIndex((x) => x.worktree === directory) + const next = layout.projects.list()[index + 1] + layout.projects.close(directory) + if (next) navigateToProject(next.worktree) + else navigate("/") + } + + async function chooseProject() { + const result = await platform.openDirectoryPickerDialog?.({ + title: "Open project", + multiple: true, + }) + if (Array.isArray(result)) { + for (const directory of result) { + openProject(directory, false) + } + navigateToProject(result[0]) + } else if (result) { + openProject(result) + } + } + + createEffect(() => { + if (!params.dir || !params.id) return + const directory = base64Decode(params.dir) + setStore("lastSession", directory, params.id) + notification.session.markViewed(params.id) + }) + + createEffect(() => { + if (isLargeViewport()) { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + } else { + document.documentElement.style.setProperty("--dialog-left-margin", "0px") + } + }) + + function getDraggableId(event: unknown): string | undefined { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined + } + + function handleDragStart(event: unknown) { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + function handleDragOver(event: DragEvent) { + const { draggable, droppable } = event + if (draggable && droppable) { + const projects = layout.projects.list() + const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString()) + const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString()) + if (fromIndex !== toIndex && toIndex !== -1) { + layout.projects.move(draggable.id.toString(), toIndex) + } + } + } + + function handleDragEnd() { + setStore("activeDraggable", undefined) + } + + const ProjectAvatar = (props: { + project: LocalProject + class?: string + expandable?: boolean + notify?: boolean + }): JSX.Element => { + const notification = useNotification() + const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const name = createMemo(() => getFilename(props.project.worktree)) + const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" + const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + + return ( +
+ 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined + } + /> + + + 0 && props.notify}> +
+ +
+ ) + } + + const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { + const name = createMemo(() => getFilename(props.project.worktree)) + const current = createMemo(() => base64Decode(params.dir ?? "")) + return ( + + + + + + + + + ) + } + + const SessionItem = (props: { + session: Session + slug: string + project: LocalProject + depth?: number + childrenMap: Map + mobile?: boolean + }): JSX.Element => { + const notification = useNotification() + const depth = props.depth ?? 0 + const children = createMemo(() => props.childrenMap.get(props.session.id) ?? []) + const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const isWorking = createMemo(() => { + if (props.session.id === params.id) return false + const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + return ( + <> +
+ + +
+ + {props.session.title} + + + + {(child) => ( + + )} + + + ) + } + + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.project.worktree) + const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const name = createMemo(() => getFilename(props.project.worktree)) + const [store, setProjectStore] = globalSync.child(props.project.worktree) + const sessions = createMemo(() => + store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)), + ) + const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) + const childSessionsByParent = createMemo(() => { + const map = new Map() + for (const session of sessions()) { + if (session.parentID) { + const children = map.get(session.parentID) ?? [] + children.push(session) + map.set(session.parentID, children) + } + } + return map + }) + const hasMoreSessions = createMemo(() => store.session.length >= store.limit) + const loadMoreSessions = async () => { + setProjectStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + const isExpanded = createMemo(() => + props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded, + ) + const handleOpenChange = (open: boolean) => { + if (props.mobile) { + if (open) mobileProjects.expand(props.project.worktree) + else mobileProjects.collapse(props.project.worktree) + } else { + if (open) layout.projects.expand(props.project.worktree) + else layout.projects.collapse(props.project.worktree) + } + } + return ( + // @ts-ignore +
+ + + + + + + + + + + + + + + +
+ ) + } + + const ProjectDragOverlay = (): JSX.Element => { + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable)) + return ( + + {(p) => ( +
+ +
+ )} +
+ ) + } + + const SidebarContent = (sidebarProps: { mobile?: boolean }) => { + const expanded = () => sidebarProps.mobile || layout.sidebar.opened() + return ( + <> +
+ + + Toggle sidebar + {command.keybind("sidebar.toggle")} +
+ } + inactive={expanded()} + > + + + + + + +
+ p.worktree)}> + + {(project) => } + + +
+ + + +
+
+
+ + +
+
+
Getting started
+
OpenCode includes free models so you can start immediately.
+
Connect any provider to use models, inc. Claude, GPT, Gemini etc.
+
+ + + +
+
+ + + + + +
+ + + Open project + + {command.keybind("project.open")} + +
+ } + inactive={expanded()} + > + +
+ + + + +
+ + ) + } + + return ( +
+
+
+
+ + + + +
+
+
{ + 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} + + +
+ ) + } + + 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 = () => ( +
+
+ + {(message) => ( + setStore("mobileStepsExpanded", message.id, (x) => !x)} + onUserInteracted={() => setStore("userInteracted", true)} + classes={{ + root: "min-w-0 w-full relative", + content: + "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + container: "px-4", + }} + /> + )} + +
+
+ ) + + 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 + }} + /> +
+
+
+ + + +
+ + + + + + {(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