summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src/pages')
-rw-r--r--packages/desktop/src/pages/directory-layout.tsx31
-rw-r--r--packages/desktop/src/pages/error.tsx155
-rw-r--r--packages/desktop/src/pages/home.tsx93
-rw-r--r--packages/desktop/src/pages/layout.tsx904
-rw-r--r--packages/desktop/src/pages/session.tsx927
5 files changed, 0 insertions, 2110 deletions
diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx
deleted file mode 100644
index c909a373d..000000000
--- a/packages/desktop/src/pages/directory-layout.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-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/desktop/src/pages/error.tsx b/packages/desktop/src/pages/error.tsx
deleted file mode 100644
index 9914279ad..000000000
--- a/packages/desktop/src/pages/error.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-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/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx
deleted file mode 100644
index 7cd2916e8..000000000
--- a/packages/desktop/src/pages/home.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-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/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
deleted file mode 100644
index 489899f88..000000000
--- a/packages/desktop/src/pages/layout.tsx
+++ /dev/null
@@ -1,904 +0,0 @@
-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/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
deleted file mode 100644
index 42e43232a..000000000
--- a/packages/desktop/src/pages/session.tsx
+++ /dev/null
@@ -1,927 +0,0 @@
-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>
- )
-}