diff options
Diffstat (limited to 'packages/desktop/src/pages')
| -rw-r--r-- | packages/desktop/src/pages/directory-layout.tsx | 31 | ||||
| -rw-r--r-- | packages/desktop/src/pages/error.tsx | 155 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 93 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 904 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 927 |
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 - <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> - ) -} |
