diff options
| author | Adam <[email protected]> | 2025-12-22 19:38:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-22 19:39:00 -0600 |
| commit | 794fe8f381c846f5241800363023d892c12cf495 (patch) | |
| tree | bff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/app/src/pages | |
| parent | a4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff) | |
| download | opencode-794fe8f381c846f5241800363023d892c12cf495.tar.gz opencode-794fe8f381c846f5241800363023d892c12cf495.zip | |
chore: rename packages/desktop -> packages/app
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/directory-layout.tsx | 31 | ||||
| -rw-r--r-- | packages/app/src/pages/error.tsx | 155 | ||||
| -rw-r--r-- | packages/app/src/pages/home.tsx | 93 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 904 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 927 |
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 + <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> + ) +} |
