summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/directory-layout.tsx31
-rw-r--r--packages/app/src/pages/error.tsx155
-rw-r--r--packages/app/src/pages/home.tsx93
-rw-r--r--packages/app/src/pages/layout.tsx904
-rw-r--r--packages/app/src/pages/session.tsx927
5 files changed, 2110 insertions, 0 deletions
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 (
+ <Show when={params.dir} keyed>
+ <SDKProvider directory={directory()}>
+ <SyncProvider>
+ {iife(() => {
+ const sync = useSync()
+ return (
+ <DataProvider data={sync.data} directory={directory()}>
+ <LocalProvider>{props.children}</LocalProvider>
+ </DataProvider>
+ )
+ })}
+ </SyncProvider>
+ </SDKProvider>
+ </Show>
+ )
+}
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<string, unknown>
+}
+
+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<ErrorPageProps> = (props) => {
+ const platform = usePlatform()
+ return (
+ <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
+ <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
+ <Logo class="w-58.5 opacity-12 shrink-0" />
+ <div class="flex flex-col items-center gap-2 text-center">
+ <h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
+ <p class="text-sm text-text-weak">An error occurred while loading the application.</p>
+ </div>
+ <TextField
+ value={formatError(props.error)}
+ readOnly
+ copyable
+ multiline
+ class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
+ label="Error Details"
+ hideLabel
+ />
+ <Button size="large" onClick={platform.restart}>
+ Restart
+ </Button>
+ <div class="flex items-center justify-center gap-1">
+ Please report this error to the OpenCode team
+ <button
+ type="button"
+ class="flex items-center text-text-interactive-base gap-1"
+ onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
+ >
+ <div>on Discord</div>
+ <Icon name="discord" class="text-text-interactive-base" />
+ </button>
+ </div>
+ </div>
+ </div>
+ )
+}
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 (
+ <div class="mx-auto mt-55">
+ <Logo class="w-xl opacity-12" />
+ <Switch>
+ <Match when={sync.data.project.length > 0}>
+ <div class="mt-20 w-full flex flex-col gap-4">
+ <div class="flex gap-2 items-center justify-between pl-3">
+ <div class="text-14-medium text-text-strong">Recent projects</div>
+ <Show when={platform.openDirectoryPickerDialog}>
+ <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
+ Open project
+ </Button>
+ </Show>
+ </div>
+ <ul class="flex flex-col gap-2">
+ <For
+ each={sync.data.project
+ .toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+ .slice(0, 5)}
+ >
+ {(project) => (
+ <Button
+ size="large"
+ variant="ghost"
+ class="text-14-mono text-left justify-between px-3"
+ onClick={() => openProject(project.worktree)}
+ >
+ {project.worktree.replace(homedir(), "~")}
+ <div class="text-14-regular text-text-weak">
+ {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
+ </div>
+ </Button>
+ )}
+ </For>
+ </ul>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="mt-30 mx-auto flex flex-col items-center gap-3">
+ <Icon name="folder-add-left" size="large" />
+ <div class="flex flex-col gap-1 items-center justify-center">
+ <div class="text-14-medium text-text-strong">No recent projects</div>
+ <div class="text-12-regular text-text-weak">Get started by opening a local project</div>
+ </div>
+ <div />
+ <Show when={platform.openDirectoryPickerDialog}>
+ <Button class="px-3" onClick={chooseProject}>
+ Open project
+ </Button>
+ </Show>
+ </div>
+ </Match>
+ </Switch>
+ </div>
+ )
+}
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<string, boolean>,
+ })
+
+ 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<string, Session[]>()
+ 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(() => <DialogSelectProvider />)
+ }
+
+ 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 (
+ <div class="relative size-5 shrink-0 rounded-sm">
+ <Avatar
+ fallback={name()}
+ src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
+ {...getAvatarColors(props.project.icon?.color)}
+ class={`size-full ${props.class ?? ""}`}
+ style={
+ notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
+ }
+ />
+ <Show when={props.expandable}>
+ <Icon
+ name="chevron-right"
+ size="normal"
+ class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
+ />
+ </Show>
+ <Show when={notifications().length > 0 && props.notify}>
+ <div
+ classList={{
+ "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
+ "bg-icon-critical-base": hasError(),
+ "bg-text-interactive-base": !hasError(),
+ }}
+ />
+ </Show>
+ </div>
+ )
+ }
+
+ const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
+ const name = createMemo(() => getFilename(props.project.worktree))
+ const current = createMemo(() => base64Decode(params.dir ?? ""))
+ return (
+ <Switch>
+ <Match when={layout.sidebar.opened()}>
+ <Button
+ as={"div"}
+ variant="ghost"
+ data-active
+ class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
+ >
+ <div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
+ <ProjectAvatar project={props.project} />
+ <span class="truncate text-14-medium text-text-strong">{name()}</span>
+ </div>
+ </Button>
+ </Match>
+ <Match when={true}>
+ <Button
+ variant="ghost"
+ size="large"
+ class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
+ data-selected={props.project.worktree === current()}
+ onClick={() => navigateToProject(props.project.worktree)}
+ >
+ <ProjectAvatar project={props.project} notify />
+ </Button>
+ </Match>
+ </Switch>
+ )
+ }
+
+ const SessionItem = (props: {
+ session: Session
+ slug: string
+ project: LocalProject
+ depth?: number
+ childrenMap: Map<string, Session[]>
+ 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 (
+ <>
+ <div
+ data-session-id={props.session.id}
+ class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
+ hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
+ style={{ "padding-left": `${16 + depth * 12}px` }}
+ >
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+ <A
+ href={`${props.slug}/session/${props.session.id}`}
+ class="flex flex-col min-w-0 text-left w-full focus:outline-none"
+ >
+ <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
+ <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+ {props.session.title}
+ </span>
+ <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+ <Switch>
+ <Match when={isWorking()}>
+ <Spinner class="size-2.5 mr-0.5" />
+ </Match>
+ <Match when={hasError()}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={notifications().length > 0}>
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ <Match when={true}>
+ <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+ {Math.abs(updated().diffNow().as("seconds")) < 60
+ ? "Now"
+ : updated()
+ .toRelative({
+ style: "short",
+ unit: ["days", "hours", "minutes"],
+ })
+ ?.replace(" ago", "")
+ ?.replace(/ days?/, "d")
+ ?.replace(" min.", "m")
+ ?.replace(" hr.", "h")}
+ </span>
+ </Match>
+ </Switch>
+ </div>
+ </div>
+ <Show when={props.session.summary?.files}>
+ <div class="flex justify-between items-center self-stretch">
+ <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+ <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+ </div>
+ </Show>
+ </A>
+ </Tooltip>
+ <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
+ <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
+ </Tooltip>
+ </div>
+ </div>
+ <For each={children()}>
+ {(child) => (
+ <SessionItem
+ session={child}
+ slug={props.slug}
+ project={props.project}
+ depth={depth + 1}
+ childrenMap={props.childrenMap}
+ mobile={props.mobile}
+ />
+ )}
+ </For>
+ </>
+ )
+ }
+
+ 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<string, Session[]>()
+ 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
+ <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
+ <Switch>
+ <Match when={showExpanded()}>
+ <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
+ <Button
+ as={"div"}
+ variant="ghost"
+ class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
+ >
+ <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
+ <ProjectAvatar
+ project={props.project}
+ class="group-hover/session:hidden"
+ expandable
+ notify={!isExpanded()}
+ />
+ <span class="truncate text-14-medium text-text-strong">{name()}</span>
+ </Collapsible.Trigger>
+ <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
+ <DropdownMenu>
+ <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content>
+ <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
+ <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+ <Tooltip placement="top" value="New session">
+ <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
+ </Tooltip>
+ </div>
+ </Button>
+ <Collapsible.Content>
+ <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
+ <For each={rootSessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={slug()}
+ project={props.project}
+ childrenMap={childSessionsByParent()}
+ mobile={props.mobile}
+ />
+ )}
+ </For>
+ <Show when={rootSessions().length === 0}>
+ <div
+ class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
+ hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
+ >
+ <div class="flex items-center self-stretch w-full">
+ <div class="flex-1 min-w-0">
+ <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
+ <A
+ href={`${slug()}/session`}
+ class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
+ >
+ <div class="flex items-center self-stretch gap-6 justify-between">
+ <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+ New session
+ </span>
+ </div>
+ </A>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+ </Show>
+ <Show when={hasMoreSessions()}>
+ <div class="relative w-full py-1">
+ <Button
+ variant="ghost"
+ class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
+ size="large"
+ onClick={loadMoreSessions}
+ >
+ Load more
+ </Button>
+ </div>
+ </Show>
+ </nav>
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ <Match when={true}>
+ <Tooltip placement="right" value={props.project.worktree}>
+ <ProjectVisual project={props.project} />
+ </Tooltip>
+ </Match>
+ </Switch>
+ </div>
+ )
+ }
+
+ const ProjectDragOverlay = (): JSX.Element => {
+ const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
+ return (
+ <Show when={project()}>
+ {(p) => (
+ <div class="bg-background-base rounded-md">
+ <ProjectVisual project={p()} />
+ </div>
+ )}
+ </Show>
+ )
+ }
+
+ const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+ const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+ return (
+ <>
+ <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+ <Show when={!sidebarProps.mobile}>
+ <Tooltip
+ class="shrink-0"
+ placement="right"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle sidebar</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
+ </div>
+ }
+ inactive={expanded()}
+ >
+ <Button
+ variant="ghost"
+ size="large"
+ class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
+ onClick={layout.sidebar.toggle}
+ >
+ <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
+ size="small"
+ class="group-hover/sidebar-toggle:hidden"
+ />
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
+ size="small"
+ class="hidden group-hover/sidebar-toggle:inline-block"
+ />
+ <Icon
+ name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
+ size="small"
+ class="hidden group-active/sidebar-toggle:inline-block"
+ />
+ </div>
+ <Show when={layout.sidebar.opened()}>
+ <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
+ Toggle sidebar
+ </div>
+ </Show>
+ </Button>
+ </Tooltip>
+ </Show>
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragXAxis />
+ <div
+ ref={sidebarProps.mobile ? undefined : scrollContainerRef}
+ class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
+ >
+ <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+ <For each={layout.projects.list()}>
+ {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
+ </For>
+ </SortableProvider>
+ </div>
+ <DragOverlay>
+ <ProjectDragOverlay />
+ </DragOverlay>
+ </DragDropProvider>
+ </div>
+ <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+ <Switch>
+ <Match when={!providers.paid().length && expanded()}>
+ <div class="rounded-md bg-background-stronger shadow-xs-border-base">
+ <div class="p-3 flex flex-col gap-2">
+ <div class="text-12-medium text-text-strong">Getting started</div>
+ <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+ <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
+ </div>
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
+ <Button
+ class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
+ size="large"
+ icon="plus"
+ onClick={connectProvider}
+ >
+ Connect provider
+ </Button>
+ </Tooltip>
+ </div>
+ </Match>
+ <Match when={true}>
+ <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
+ <Button
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="plus"
+ onClick={connectProvider}
+ >
+ <Show when={expanded()}>Connect provider</Show>
+ </Button>
+ </Tooltip>
+ </Match>
+ </Switch>
+ <Show when={platform.openDirectoryPickerDialog}>
+ <Tooltip
+ placement="right"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Open project</span>
+ <Show when={!sidebarProps.mobile}>
+ <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
+ </Show>
+ </div>
+ }
+ inactive={expanded()}
+ >
+ <Button
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="folder-add-left"
+ onClick={chooseProject}
+ >
+ <Show when={expanded()}>Open project</Show>
+ </Button>
+ </Tooltip>
+ </Show>
+ <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
+ <Button
+ as={"a"}
+ href="https://opencode.ai/desktop-feedback"
+ target="_blank"
+ class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+ variant="ghost"
+ size="large"
+ icon="bubble-5"
+ >
+ <Show when={expanded()}>Share feedback</Show>
+ </Button>
+ </Tooltip>
+ </div>
+ </>
+ )
+ }
+
+ return (
+ <div class="relative flex-1 min-h-0 flex flex-col">
+ <Header
+ navigateToProject={navigateToProject}
+ navigateToSession={navigateToSession}
+ onMobileMenuToggle={mobileSidebar.toggle}
+ />
+ <div class="flex-1 min-h-0 flex">
+ <div
+ classList={{
+ "hidden xl:flex": true,
+ "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
+ "flex-col gap-5.5 items-start self-stretch justify-between": true,
+ "border-r border-border-weak-base contain-strict": true,
+ }}
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+ >
+ <Show when={layout.sidebar.opened()}>
+ <ResizeHandle
+ direction="horizontal"
+ size={layout.sidebar.width()}
+ min={150}
+ max={window.innerWidth * 0.3}
+ collapseThreshold={80}
+ onResize={layout.sidebar.resize}
+ onCollapse={layout.sidebar.close}
+ />
+ </Show>
+ <SidebarContent />
+ </div>
+ <div class="xl:hidden">
+ <div
+ classList={{
+ "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
+ "opacity-100 pointer-events-auto": mobileSidebar.open(),
+ "opacity-0 pointer-events-none": !mobileSidebar.open(),
+ }}
+ onClick={(e) => {
+ if (e.target === e.currentTarget) mobileSidebar.hide()
+ }}
+ />
+ <div
+ classList={{
+ "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
+ "translate-x-0": mobileSidebar.open(),
+ "-translate-x-full": !mobileSidebar.open(),
+ }}
+ onClick={(e) => e.stopPropagation()}
+ >
+ <SidebarContent mobile />
+ </div>
+ </div>
+
+ <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
+ </div>
+ <Toast.Region />
+ </div>
+ )
+}
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<string, boolean>,
+ 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(() => <DialogSelectFile />),
+ },
+ // {
+ // 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(() => <DialogSelectModel />),
+ },
+ {
+ 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
+ <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+ <div class="relative h-full">
+ <Tabs.Trigger
+ value={props.terminal.id}
+ closeButton={
+ terminal.all().length > 1 && (
+ <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
+ )
+ }
+ >
+ {props.terminal.title}
+ </Tabs.Trigger>
+ </div>
+ </div>
+ )
+ }
+
+ const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
+ return (
+ <div class="flex items-center gap-x-1.5">
+ <FileIcon
+ node={props.file}
+ classList={{
+ "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
+ "grayscale-0": props.active,
+ }}
+ />
+ <span
+ classList={{
+ "text-14-medium": true,
+ "text-primary": !!props.file.status?.status,
+ italic: !props.file.pinned,
+ }}
+ >
+ {props.file.name}
+ </span>
+ <span class="hidden opacity-70">
+ <Switch>
+ <Match when={props.file.status?.status === "modified"}>
+ <span class="text-primary">M</span>
+ </Match>
+ <Match when={props.file.status?.status === "added"}>
+ <span class="text-success">A</span>
+ </Match>
+ <Match when={props.file.status?.status === "deleted"}>
+ <span class="text-error">D</span>
+ </Match>
+ </Switch>
+ </span>
+ </div>
+ )
+ }
+
+ 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
+ <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+ <div class="relative h-full">
+ <Tabs.Trigger
+ value={props.tab}
+ closeButton={
+ <Tooltip value="Close tab" placement="bottom">
+ <IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
+ </Tooltip>
+ }
+ hideCloseButton
+ onClick={() => props.onTabClick(props.tab)}
+ >
+ <Switch>
+ <Match when={file()}>{(f) => <FileVisual file={f()} />}</Match>
+ </Switch>
+ </Tabs.Trigger>
+ </div>
+ </div>
+ )
+ }
+
+ 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 = () => (
+ <div
+ ref={mobileAutoScroll.scrollRef}
+ onScroll={mobileAutoScroll.handleScroll}
+ onClick={mobileAutoScroll.handleInteraction}
+ class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
+ >
+ <div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
+ <For each={visibleUserMessages()}>
+ {(message) => (
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={message.id}
+ stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
+ onStepsExpandedToggle={() => 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",
+ }}
+ />
+ )}
+ </For>
+ </div>
+ </div>
+ )
+
+ const NewSessionView = () => (
+ <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+ <div class="text-20-medium text-text-weaker">New session</div>
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="folder" size="small" />
+ <div class="text-12-medium text-text-weak">
+ {getDirectory(sync.data.path.directory)}
+ <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+ </div>
+ </div>
+ <Show when={sync.project}>
+ {(project) => (
+ <div class="flex justify-center items-center gap-3">
+ <Icon name="pencil-line" size="small" />
+ <div class="text-12-medium text-text-weak">
+ Last modified&nbsp;
+ <span class="text-text-strong">
+ {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+ </span>
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ )
+
+ const DesktopSessionContent = () => (
+ <Switch>
+ <Match when={params.id}>
+ <div class="flex items-start justify-start h-full min-h-0">
+ <SessionMessageRail
+ messages={visibleUserMessages()}
+ current={activeMessage()}
+ onMessageSelect={setActiveMessage}
+ wide={!showTabs()}
+ />
+ <Show when={activeMessage()}>
+ <SessionTurn
+ sessionID={params.id!}
+ messageID={activeMessage()!.id}
+ stepsExpanded={store.stepsExpanded}
+ onStepsExpandedToggle={() => 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"),
+ }}
+ />
+ </Show>
+ </div>
+ </Match>
+ <Match when={true}>
+ <NewSessionView />
+ </Match>
+ </Switch>
+ )
+
+ return (
+ <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+ <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
+ <Switch>
+ <Match when={!params.id}>
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <NewSessionView />
+ </div>
+ </Match>
+ <Match when={diffs().length > 0}>
+ <Tabs class="flex-1 min-h-0 flex flex-col pb-28">
+ <Tabs.List>
+ <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+ Session
+ </Tabs.Trigger>
+ <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+ {diffs().length} Files Changed
+ </Tabs.Trigger>
+ </Tabs.List>
+ <Tabs.Content value="session" class="flex-1 !overflow-hidden">
+ <MobileTurns />
+ </Tabs.Content>
+ <Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
+ <div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
+ <SessionReview
+ diffs={diffs()}
+ classes={{
+ root: "pb-32",
+ header: "px-4",
+ container: "px-4",
+ }}
+ />
+ </div>
+ </Tabs.Content>
+ </Tabs>
+ </Match>
+ <Match when={true}>
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <MobileTurns />
+ </div>
+ </Match>
+ </Switch>
+ <div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
+ <div class="w-full">
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
+ </div>
+ </div>
+ </div>
+
+ <div class="hidden md:flex min-h-0 grow w-full">
+ <div
+ class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
+ style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
+ >
+ <div class="flex-1 min-h-0 overflow-hidden">
+ <DesktopSessionContent />
+ </div>
+ <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
+ <div
+ classList={{
+ "w-full px-6": true,
+ "max-w-200": !showTabs(),
+ }}
+ >
+ <PromptInput
+ ref={(el) => {
+ inputRef = el
+ }}
+ />
+ </div>
+ </div>
+ <Show when={showTabs()}>
+ <ResizeHandle
+ direction="horizontal"
+ size={layout.session.width()}
+ min={450}
+ max={window.innerWidth * 0.45}
+ onResize={layout.session.resize}
+ />
+ </Show>
+ </div>
+
+ <Show when={showTabs()}>
+ <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
+ <DragDropProvider
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
+ <div class="sticky top-0 shrink-0 flex">
+ <Tabs.List>
+ <Show when={diffs().length}>
+ <Tabs.Trigger value="review">
+ <div class="flex items-center gap-3">
+ <Show when={diffs()}>
+ <DiffChanges changes={diffs()} variant="bars" />
+ </Show>
+ <div class="flex items-center gap-1.5">
+ <div>Review</div>
+ <Show when={info()?.summary?.files}>
+ <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+ {info()?.summary?.files ?? 0}
+ </div>
+ </Show>
+ </div>
+ </div>
+ </Tabs.Trigger>
+ </Show>
+ <SortableProvider ids={tabs().all() ?? []}>
+ <For each={tabs().all() ?? []}>
+ {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
+ </For>
+ </SortableProvider>
+ <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+ <Tooltip
+ value={
+ <div class="flex items-center gap-2">
+ <span>Open file</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
+ </div>
+ }
+ class="flex items-center"
+ >
+ <IconButton
+ icon="plus-small"
+ variant="ghost"
+ iconSize="large"
+ onClick={() => dialog.show(() => <DialogSelectFile />)}
+ />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ </div>
+ <Show when={diffs().length}>
+ <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+ <SessionReview
+ classes={{
+ root: "pb-40",
+ header: "px-6",
+ container: "px-6",
+ }}
+ diffs={diffs()}
+ split
+ />
+ </div>
+ </Tabs.Content>
+ </Show>
+ <For each={tabs().all()}>
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <Tabs.Content value={tab} class="select-text mt-3">
+ <Switch>
+ <Match when={file()}>
+ {(f) => (
+ <Dynamic
+ component={codeComponent}
+ file={{
+ name: f().path,
+ contents: f().content?.content ?? "",
+ cacheKey: checksum(f().content?.content ?? ""),
+ }}
+ overflow="scroll"
+ class="pb-40"
+ />
+ )}
+ </Match>
+ </Switch>
+ </Tabs.Content>
+ )
+ }}
+ </For>
+ </Tabs>
+ <DragOverlay>
+ <Show when={store.activeDraggable}>
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+ <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+ <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
+ </div>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ </div>
+ </Show>
+ </div>
+
+ <Show when={layout.terminal.opened()}>
+ <div
+ class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
+ style={{ height: `${layout.terminal.height()}px` }}
+ >
+ <ResizeHandle
+ direction="vertical"
+ size={layout.terminal.height()}
+ min={100}
+ max={window.innerHeight * 0.6}
+ collapseThreshold={50}
+ onResize={layout.terminal.resize}
+ onCollapse={layout.terminal.close}
+ />
+ <DragDropProvider
+ onDragStart={handleTerminalDragStart}
+ onDragEnd={handleTerminalDragEnd}
+ onDragOver={handleTerminalDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
+ <Tabs.List class="h-10">
+ <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+ <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
+ </SortableProvider>
+ <div class="h-full flex items-center justify-center">
+ <Tooltip
+ value={
+ <div class="flex items-center gap-2">
+ <span>New terminal</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
+ </div>
+ }
+ class="flex items-center"
+ >
+ <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
+ </Tooltip>
+ </div>
+ </Tabs.List>
+ <For each={terminal.all()}>
+ {(pty) => (
+ <Tabs.Content value={pty.id}>
+ <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
+ </Tabs.Content>
+ )}
+ </For>
+ </Tabs>
+ <DragOverlay>
+ <Show when={store.activeTerminalDraggable}>
+ {(draggedId) => {
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+ <Show when={pty()}>
+ {(t) => (
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+ {t().title}
+ </div>
+ )}
+ </Show>
+ )
+ }}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ </div>
+ </Show>
+ </div>
+ )
+}