diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/pages | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/pages')
37 files changed, 4490 insertions, 3281 deletions
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index da4667a82..2f4db8564 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) { showToast({ variant: "error", title: language.t("common.requestFailed"), - description: "Invalid directory in URL.", + description: language.t("directory.error.invalidUrl"), }) navigate("/") }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3b66258c9..59adef469 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2,53 +2,34 @@ import { batch, createEffect, createMemo, - createSignal, For, - Match, on, onCleanup, onMount, ParentProps, Show, - Switch, untrack, - type Accessor, type JSX, } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" -import { useLayout, getAvatarColors, LocalProject } from "@/context/layout" +import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" -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 { InlineInput } from "@opencode-ai/ui/inline-input" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { HoverCard } from "@opencode-ai/ui/hover-card" -import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { ContextMenu } from "@opencode-ai/ui/context-menu" -import { Collapsible } from "@opencode-ai/ui/collapsible" -import { DiffChanges } from "@opencode-ai/ui/diff-changes" -import { Spinner } from "@opencode-ai/ui/spinner" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" -import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" +import { Session, type Message } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" -import { - DragDropProvider, - DragDropSensors, - DragOverlay, - SortableProvider, - closestCenter, - createSortable, -} from "@thisbeyond/solid-dnd" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" @@ -60,7 +41,6 @@ import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" import { createAim } from "@/utils/aim" import { Worktree as WorktreeState } from "@/utils/worktree" -import { agentColor } from "@/utils/agent" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -75,44 +55,26 @@ import { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" - -const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - -const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") - -function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } -} - -const isRootVisibleSession = (session: Session, directory: string) => - workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived - -const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => - store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) - -const childMapByParent = (sessions: Session[]) => { - const map = new Map<string, string[]>() - for (const session of sessions) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map -} +import { + childMapByParent, + displayName, + errorMessage, + getDraggableId, + sortedRootSessions, + syncWorkspaceOrder, + workspaceKey, +} from "./layout/helpers" +import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links" +import { createInlineEditorController } from "./layout/inline-editor" +import { + LocalWorkspace, + SortableWorkspace, + WorkspaceDragOverlay, + type WorkspaceSidebarContext, +} from "./layout/sidebar-workspace" +import { workspaceOpenState } from "./layout/sidebar-workspace-helpers" +import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" +import { SidebarContent } from "./layout/sidebar-shell" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -168,10 +130,7 @@ export default function Layout(props: ParentProps) { nav: undefined as HTMLElement | undefined, }) - const [editor, setEditor] = createStore({ - active: "" as string, - value: "", - }) + const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) setState("busyWorkspaces", (prev) => { @@ -202,6 +161,8 @@ export default function Layout(props: ParentProps) { const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) + const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined)) + const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const hoverProjectData = createMemo(() => { const id = state.hoverProject @@ -253,99 +214,11 @@ export default function Layout(props: ParentProps) { setState("autoselect", false) }) - const editorOpen = (id: string) => editor.active === id - const editorValue = () => editor.value - - const openEditor = (id: string, value: string) => { - if (!id) return - setEditor({ active: id, value }) - } - - const closeEditor = () => setEditor({ active: "", value: "" }) - - const saveEditor = (callback: (next: string) => void) => { - const next = editor.value.trim() - if (!next) { - closeEditor() - return - } - closeEditor() - callback(next) - } - - const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { - if (event.key === "Enter") { - event.preventDefault() - saveEditor(callback) - return - } - if (event.key === "Escape") { - event.preventDefault() - closeEditor() - } - } - - const InlineEditor = (props: { - id: string - value: Accessor<string> - onSave: (next: string) => void - class?: string - displayClass?: string - editing?: boolean - stopPropagation?: boolean - openOnDblClick?: boolean - }) => { - const isEditing = () => props.editing ?? editorOpen(props.id) - const stopEvents = () => props.stopPropagation ?? false - const allowDblClick = () => props.openOnDblClick ?? true - const stopPropagation = (event: Event) => { - if (!stopEvents()) return - event.stopPropagation() - } - const handleDblClick = (event: MouseEvent) => { - if (!allowDblClick()) return - stopPropagation(event) - openEditor(props.id, props.value()) - } - - return ( - <Show - when={isEditing()} - fallback={ - <span - class={props.displayClass ?? props.class} - onDblClick={handleDblClick} - onPointerDown={stopPropagation} - onMouseDown={stopPropagation} - onClick={stopPropagation} - onTouchStart={stopPropagation} - > - {props.value()} - </span> - } - > - <InlineInput - ref={(el) => { - requestAnimationFrame(() => el.focus()) - }} - value={editorValue()} - class={props.class} - onInput={(event) => setEditor("value", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - editorKeyDown(event, props.onSave) - }} - onBlur={() => closeEditor()} - onPointerDown={stopPropagation} - onClick={stopPropagation} - onDblClick={stopPropagation} - onMouseDown={stopPropagation} - onMouseUp={stopPropagation} - onTouchStart={stopPropagation} - /> - </Show> - ) - } + const editorOpen = editor.editorOpen + const openEditor = editor.openEditor + const closeEditor = editor.closeEditor + const setEditor = editor.setEditor + const InlineEditor = editor.InlineEditor function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) @@ -670,15 +543,12 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [project.worktree, ...(project.sandboxes ?? [])] const existing = store.workspaceOrder[project.worktree] + const merged = syncWorkspaceOrder(local, dirs, existing) if (!existing) { - setStore("workspaceOrder", project.worktree, dirs) + setStore("workspaceOrder", project.worktree, merged) return } - const keep = existing.filter((d) => d !== local && dirs.includes(d)) - const missing = dirs.filter((d) => d !== local && !existing.includes(d)) - const merged = [local, ...missing, ...keep] - if (merged.length !== existing.length) { setStore("workspaceOrder", project.worktree, merged) return @@ -1241,33 +1111,13 @@ export default function Layout(props: ParentProps) { if (navigate) navigateToProject(directory) } - const deepLinkEvent = "opencode:deep-link" - - const parseDeepLink = (input: string) => { - if (!input.startsWith("opencode://")) return - const url = new URL(input) - if (url.hostname !== "open-project") return - const directory = url.searchParams.get("directory") - if (!directory) return - return directory - } - const handleDeepLinks = (urls: string[]) => { if (!server.isLocal()) return - for (const input of urls) { - const directory = parseDeepLink(input) - if (!directory) continue + for (const directory of collectOpenProjectDeepLinks(urls)) { openProject(directory) } } - const drainDeepLinks = () => { - const pending = window.__OPENCODE__?.deepLinks ?? [] - if (pending.length === 0) return - if (window.__OPENCODE__) window.__OPENCODE__.deepLinks = [] - handleDeepLinks(pending) - } - onMount(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ urls: string[] }>).detail @@ -1276,13 +1126,11 @@ export default function Layout(props: ParentProps) { handleDeepLinks(urls) } - drainDeepLinks() + handleDeepLinks(drainPendingDeepLinks(window)) window.addEventListener(deepLinkEvent, handler as EventListener) onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener)) }) - const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) - async function renameProject(project: LocalProject, next: string) { const current = displayName(project) if (next === current) return @@ -1310,6 +1158,18 @@ export default function Layout(props: ParentProps) { else navigate("/") } + function toggleProjectWorkspaces(project: LocalProject) { + const enabled = layout.sidebar.workspaces(project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(project.worktree) + return + } + if (project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(project.worktree) + } + + const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />) + async function chooseProject() { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { @@ -1336,15 +1196,6 @@ export default function Layout(props: ParentProps) { } } - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - const deleteWorkspace = async (root: string, directory: string) => { if (directory === root) return @@ -1356,7 +1207,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.delete.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return false }) @@ -1395,7 +1246,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.reset.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return false }) @@ -1622,14 +1473,6 @@ export default function Layout(props: ParentProps) { globalSync.project.loadSessions(project.worktree) }) - 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 @@ -1665,9 +1508,8 @@ export default function Layout(props: ParentProps) { const existing = store.workspaceOrder[project.worktree] if (!existing) return extra ? [...dirs, extra] : dirs - const keep = existing.filter((d) => d !== local && dirs.includes(d)) - const missing = dirs.filter((d) => d !== local && !existing.includes(d)) - const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep] + const merged = syncWorkspaceOrder(local, dirs, existing) + if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)] if (!extra) return merged if (pending) return merged return [...merged, extra] @@ -1710,830 +1552,6 @@ export default function Layout(props: ParentProps) { setStore("activeWorkspace", undefined) } - const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { - const notification = useNotification() - const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) - const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) - const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - return ( - <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> - <div class="size-full rounded overflow-clip"> - <Avatar - fallback={name()} - src={ - props.project.id === OPENCODE_PROJECT_ID - ? "https://opencode.ai/favicon.svg" - : props.project.icon?.override - } - {...getAvatarColors(props.project.icon?.color)} - class="size-full rounded" - classList={{ "badge-mask": unseenCount() > 0 && props.notify }} - /> - </div> - <Show when={unseenCount() > 0 && props.notify}> - <div - classList={{ - "absolute top-px right-px size-1.5 rounded-full z-10": true, - "bg-icon-critical-base": hasError(), - "bg-text-interactive-base": !hasError(), - }} - /> - </Show> - </div> - ) - } - - const SessionItem = (props: { - session: Session - slug: string - mobile?: boolean - dense?: boolean - popover?: boolean - children: Map<string, string[]> - }): JSX.Element => { - const notification = useNotification() - const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) - const [sessionStore] = globalSync.child(props.session.directory) - const hasPermissions = createMemo(() => { - const permissions = sessionStore.permission?.[props.session.id] ?? [] - if (permissions.length > 0) return true - - for (const id of props.children.get(props.session.id) ?? []) { - const childPermissions = sessionStore.permission?.[id] ?? [] - if (childPermissions.length > 0) return true - } - return false - }) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - const status = sessionStore.session_status[props.session.id] - return status?.type === "busy" || status?.type === "retry" - }) - - const tint = createMemo(() => { - const messages = sessionStore.message[props.session.id] - if (!messages) return undefined - let user: Message | undefined - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (message.role !== "user") continue - user = message - break - } - if (!user?.agent) return undefined - - const agent = sessionStore.agent.find((a) => a.name === user.agent) - return agentColor(user.agent, agent?.color) - }) - - const hoverMessages = createMemo(() => - sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), - ) - const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) - const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) - const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) - const isActive = createMemo(() => props.session.id === params.id) - - const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined } - const cancelHoverPrefetch = () => { - if (hoverPrefetch.current === undefined) return - clearTimeout(hoverPrefetch.current) - hoverPrefetch.current = undefined - } - const scheduleHoverPrefetch = () => { - if (hoverPrefetch.current !== undefined) return - hoverPrefetch.current = setTimeout(() => { - hoverPrefetch.current = undefined - prefetchSession(props.session) - }, 200) - } - - onCleanup(cancelHoverPrefetch) - - const messageLabel = (message: Message) => { - const parts = sessionStore.part[message.id] ?? [] - const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) - return text?.text - } - - const item = ( - <A - href={`${props.slug}/session/${props.session.id}`} - class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} - onPointerEnter={scheduleHoverPrefetch} - onPointerLeave={cancelHoverPrefetch} - onMouseEnter={scheduleHoverPrefetch} - onMouseLeave={cancelHoverPrefetch} - onFocus={() => prefetchSession(props.session, "high")} - onClick={() => { - setState("hoverSession", undefined) - if (layout.sidebar.opened()) return - queueMicrotask(() => setState("hoverProject", undefined)) - }} - > - <div class="flex items-center gap-1 w-full"> - <div - class="shrink-0 size-6 flex items-center justify-center" - style={{ color: tint() ?? "var(--icon-interactive-base)" }} - > - <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> - <Match when={isWorking()}> - <Spinner class="size-[15px]" /> - </Match> - <Match when={hasPermissions()}> - <div class="size-1.5 rounded-full bg-surface-warning-strong" /> - </Match> - <Match when={hasError()}> - <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> - </Match> - <Match when={unseenCount() > 0}> - <div class="size-1.5 rounded-full bg-text-interactive-base" /> - </Match> - </Switch> - </div> - <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> - {props.session.title} - </span> - <Show when={props.session.summary}> - {(summary) => ( - <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> - <DiffChanges changes={summary()} /> - </div> - )} - </Show> - </div> - </A> - ) - - return ( - <div - data-session-id={props.session.id} - class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 - hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" - > - <Show - when={hoverEnabled()} - fallback={ - <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> - {item} - </Tooltip> - } - > - <HoverCard - openDelay={1000} - closeDelay={sidebarHovering() ? 600 : 0} - placement="right-start" - gutter={16} - shift={-2} - trigger={item} - mount={!props.mobile ? state.nav : undefined} - open={state.hoverSession === props.session.id} - onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)} - > - <Show - when={hoverReady()} - fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>} - > - <div class="overflow-y-auto max-h-72 h-full"> - <MessageNav - messages={hoverMessages() ?? []} - current={undefined} - getLabel={messageLabel} - onMessageSelect={(message) => { - if (!isActive()) { - layout.pendingMessage.set( - `${base64Encode(props.session.directory)}/${props.session.id}`, - message.id, - ) - navigate(`${props.slug}/session/${props.session.id}`) - return - } - window.history.replaceState(null, "", `#message-${message.id}`) - window.dispatchEvent(new HashChangeEvent("hashchange")) - }} - size="normal" - class="w-60" - /> - </div> - </Show> - </HoverCard> - </Show> - <div - class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} - classList={{ - "opacity-100 pointer-events-auto": !!props.mobile, - "opacity-0 pointer-events-none": !props.mobile, - "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, - "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, - }} - > - <Tooltip value={language.t("common.archive")} placement="top"> - <IconButton - icon="archive" - variant="ghost" - class="size-6 rounded-md" - aria-label={language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void archiveSession(props.session) - }} - /> - </Tooltip> - </div> - </div> - ) - } - - const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { - const label = language.t("command.session.new") - const tooltip = () => props.mobile || !sidebarExpanded() - const item = ( - <A - href={`${props.slug}/session`} - end - class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} - onClick={() => { - setState("hoverSession", undefined) - if (layout.sidebar.opened()) return - queueMicrotask(() => setState("hoverProject", undefined)) - }} - > - <div class="flex items-center gap-1 w-full"> - <div class="shrink-0 size-6 flex items-center justify-center"> - <Icon name="plus-small" size="small" class="text-icon-weak" /> - </div> - <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> - {label} - </span> - </div> - </A> - ) - - return ( - <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"> - <Show - when={!tooltip()} - fallback={ - <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}> - {item} - </Tooltip> - } - > - {item} - </Show> - </div> - ) - } - - const SessionSkeleton = (props: { count?: number }): JSX.Element => { - const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) - return ( - <div class="flex flex-col gap-1"> - <For each={items}> - {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />} - </For> - </div> - ) - } - - const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) - return ( - <Show when={project()}> - {(p) => ( - <div class="bg-background-base rounded-xl p-1"> - <ProjectIcon project={p()} /> - </div> - )} - </Show> - ) - } - - const WorkspaceDragOverlay = (): JSX.Element => { - const label = createMemo(() => { - const project = sidebarProject() - if (!project) return - const directory = store.activeWorkspace - if (!directory) return - - const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) - const kind = - directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) - return `${kind} : ${name}` - }) - - return ( - <Show when={label()}> - {(value) => ( - <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div> - )} - </Show> - ) - } - - const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.directory) - const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) - const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) - const children = createMemo(() => childMapByParent(workspaceStore.session)) - const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => currentDir() === props.directory) - const workspaceValue = createMemo(() => { - const branch = workspaceStore.vcs?.branch - const name = branch ?? getFilename(props.directory) - return workspaceName(props.directory, props.project.id, branch) ?? name - }) - const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) - const boot = createMemo(() => open() || active()) - const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) - const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) - const busy = createMemo(() => isBusy(props.directory)) - const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) - const loadMore = async () => { - setWorkspaceStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.directory) - } - - const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`)) - - const openWrapper = (value: boolean) => { - setStore("workspaceExpanded", props.directory, value) - if (value) return - if (editorOpen(`workspace:${props.directory}`)) closeEditor() - } - - createEffect(() => { - if (!boot()) return - globalSync.child(props.directory, { bootstrap: true }) - }) - - const header = () => ( - <div class="flex items-center gap-1 min-w-0 flex-1"> - <div class="flex items-center justify-center shrink-0 size-6"> - <Show when={busy()} fallback={<Icon name="branch" size="small" />}> - <Spinner class="size-[15px]" /> - </Show> - </div> - <span class="text-14-medium text-text-base shrink-0"> - {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : - </span> - <Show - when={!local()} - fallback={ - <span class="text-14-medium text-text-base min-w-0 truncate"> - {workspaceStore.vcs?.branch ?? getFilename(props.directory)} - </span> - } - > - <InlineEditor - id={`workspace:${props.directory}`} - value={workspaceValue} - onSave={(next) => { - const trimmed = next.trim() - if (!trimmed) return - renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) - setEditor("value", workspaceValue()) - }} - class="text-14-medium text-text-base min-w-0 truncate" - displayClass="text-14-medium text-text-base min-w-0 truncate" - editing={workspaceEditActive()} - stopPropagation={false} - openOnDblClick={false} - /> - </Show> - <Icon - name={open() ? "chevron-down" : "chevron-right"} - size="small" - class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100" - /> - </div> - ) - - return ( - <div - // @ts-ignore - use:sortable - classList={{ - "opacity-30": sortable.isActiveDraggable, - "opacity-50 pointer-events-none": busy(), - }} - > - <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}> - <div class="px-2 py-1"> - <div - class="group/workspace relative" - data-component="workspace-item" - data-workspace={base64Encode(props.directory)} - > - <div class="flex items-center gap-1"> - <Show - when={workspaceEditActive()} - fallback={ - <Collapsible.Trigger - class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover" - data-action="workspace-toggle" - data-workspace={base64Encode(props.directory)} - > - {header()} - </Collapsible.Trigger> - } - > - <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div> - </Show> - <div - class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" - classList={{ - "opacity-100 pointer-events-auto": menu.open, - "opacity-0 pointer-events-none": !menu.open, - "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, - "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, - }} - > - <DropdownMenu - modal={!sidebarHovering()} - open={menu.open} - onOpenChange={(open) => setMenu("open", open)} - > - <Tooltip value={language.t("common.moreOptions")} placement="top"> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md" - data-action="workspace-menu" - data-workspace={base64Encode(props.directory)} - aria-label={language.t("common.moreOptions")} - /> - </Tooltip> - <DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}> - <DropdownMenu.Content - onCloseAutoFocus={(event) => { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`workspace:${props.directory}`, workspaceValue()) - }} - > - <DropdownMenu.Item - disabled={local()} - onSelect={() => { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item - disabled={local() || busy()} - onSelect={() => - dialog.show(() => ( - <DialogResetWorkspace root={props.project.worktree} directory={props.directory} /> - )) - } - > - <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item - disabled={local() || busy()} - onSelect={() => - dialog.show(() => ( - <DialogDeleteWorkspace root={props.project.worktree} directory={props.directory} /> - )) - } - > - <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - </div> - </div> - </div> - - <Collapsible.Content> - <nav class="flex flex-col gap-1 px-2"> - <NewSessionItem slug={slug()} mobile={props.mobile} /> - <Show when={loading()}> - <SessionSkeleton /> - </Show> - <For each={sessions()}> - {(session) => ( - <SessionItem session={session} slug={slug()} mobile={props.mobile} children={children()} /> - )} - </For> - <Show when={hasMore()}> - <div class="relative w-full py-1"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" - size="large" - onClick={(e: MouseEvent) => { - loadMore() - ;(e.currentTarget as HTMLButtonElement).blur() - }} - > - {language.t("common.loadMore")} - </Button> - </div> - </Show> - </nav> - </Collapsible.Content> - </Collapsible> - </div> - ) - } - - const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.project.worktree) - const selected = createMemo(() => { - const current = currentDir() - return props.project.worktree === current || props.project.sandboxes?.includes(current) - }) - - const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) - const workspaceEnabled = createMemo( - () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), - ) - const [open, setOpen] = createSignal(false) - const [menu, setMenu] = createSignal(false) - - const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) - const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo( - () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), - ) - - createEffect(() => { - if (preview()) return - if (!open()) return - setOpen(false) - }) - - const label = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - const kind = - directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") - const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) - return `${kind} : ${name}` - } - - const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) - const projectChildren = createMemo(() => childMapByParent(projectStore().session)) - const workspaceSessions = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, Date.now()).slice(0, 2) - } - const workspaceChildren = (directory: string) => { - const [data] = globalSync.child(directory, { bootstrap: false }) - return childMapByParent(data.session) - } - - const projectName = () => props.project.name || getFilename(props.project.worktree) - const Trigger = () => ( - <ContextMenu - modal={!sidebarHovering()} - onOpenChange={(value) => { - setMenu(value) - if (value) setOpen(false) - }} - > - <ContextMenu.Trigger - as="button" - type="button" - aria-label={projectName()} - data-action="project-switch" - data-project={base64Encode(props.project.worktree)} - classList={{ - "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, - "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), - "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected() && !active(), - "bg-surface-base-hover border border-border-weak-base": !selected() && active(), - }} - onMouseEnter={(event: MouseEvent) => { - if (!overlay()) return - aim.enter(props.project.worktree, event) - }} - onMouseLeave={() => { - if (!overlay()) return - aim.leave(props.project.worktree) - }} - onFocus={() => { - if (!overlay()) return - aim.activate(props.project.worktree) - }} - onClick={() => navigateToProject(props.project.worktree)} - onBlur={() => setOpen(false)} - > - <ProjectIcon project={props.project} notify /> - </ContextMenu.Trigger> - <ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}> - <ContextMenu.Content> - <ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}> - <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel> - </ContextMenu.Item> - <ContextMenu.Item - data-action="project-workspaces-toggle" - data-project={base64Encode(props.project.worktree)} - disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()} - onSelect={() => { - const enabled = layout.sidebar.workspaces(props.project.worktree)() - if (enabled) { - layout.sidebar.toggleWorkspaces(props.project.worktree) - return - } - if (props.project.vcs !== "git") return - layout.sidebar.toggleWorkspaces(props.project.worktree) - }} - > - <ContextMenu.ItemLabel> - {layout.sidebar.workspaces(props.project.worktree)() - ? language.t("sidebar.workspaces.disable") - : language.t("sidebar.workspaces.enable")} - </ContextMenu.ItemLabel> - </ContextMenu.Item> - <ContextMenu.Separator /> - <ContextMenu.Item - data-action="project-close-menu" - data-project={base64Encode(props.project.worktree)} - onSelect={() => closeProject(props.project.worktree)} - > - <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel> - </ContextMenu.Item> - </ContextMenu.Content> - </ContextMenu.Portal> - </ContextMenu> - ) - - return ( - // @ts-ignore - <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <Show when={preview()} fallback={<Trigger />}> - <HoverCard - open={open() && !menu()} - openDelay={0} - closeDelay={0} - placement="right-start" - gutter={6} - trigger={<Trigger />} - onOpenChange={(value) => { - if (menu()) return - setOpen(value) - if (value) setState("hoverSession", undefined) - }} - > - <div class="-m-3 p-2 flex flex-col w-72"> - <div class="px-4 pt-2 pb-1 flex items-center gap-2"> - <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div> - <Tooltip value={language.t("common.close")} placement="top" gutter={6}> - <IconButton - icon="circle-x" - variant="ghost" - class="shrink-0" - data-action="project-close-hover" - data-project={base64Encode(props.project.worktree)} - aria-label={language.t("common.close")} - onClick={(event) => { - event.stopPropagation() - setOpen(false) - closeProject(props.project.worktree) - }} - /> - </Tooltip> - </div> - <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div> - <div class="px-2 pb-2 flex flex-col gap-2"> - <Show - when={workspaceEnabled()} - fallback={ - <For each={projectSessions()}> - {(session) => ( - <SessionItem - session={session} - slug={base64Encode(props.project.worktree)} - dense - mobile={props.mobile} - popover={false} - children={projectChildren()} - /> - )} - </For> - } - > - <For each={workspaces()}> - {(directory) => { - const sessions = createMemo(() => workspaceSessions(directory)) - const children = createMemo(() => workspaceChildren(directory)) - return ( - <div class="flex flex-col gap-1"> - <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> - <div class="shrink-0 size-6 flex items-center justify-center"> - <Icon name="branch" size="small" class="text-icon-base" /> - </div> - <span class="truncate text-14-medium text-text-base">{label(directory)}</span> - </div> - <For each={sessions()}> - {(session) => ( - <SessionItem - session={session} - slug={base64Encode(directory)} - dense - mobile={props.mobile} - popover={false} - children={children()} - /> - )} - </For> - </div> - ) - }} - </For> - </Show> - </div> - <div class="px-2 py-2 border-t border-border-weak-base"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" - onClick={() => { - layout.sidebar.open() - setOpen(false) - if (selected()) { - return - } - navigateToProject(props.project.worktree) - }} - > - {language.t("sidebar.project.viewAllSessions")} - </Button> - </div> - </div> - </HoverCard> - </Show> - </div> - ) - } - - const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const workspace = createMemo(() => { - const [store, setStore] = globalSync.child(props.project.worktree) - return { store, setStore } - }) - const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) - const children = createMemo(() => childMapByParent(workspace().store.session)) - const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) - const loading = createMemo(() => !booted() && sessions().length === 0) - const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) - const loadMore = async () => { - workspace().setStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.project.worktree) - } - - return ( - <div - ref={(el) => { - if (!props.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - <nav class="flex flex-col gap-1 px-2"> - <Show when={loading()}> - <SessionSkeleton /> - </Show> - <For each={sessions()}> - {(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} children={children()} />} - </For> - <Show when={hasMore()}> - <div class="relative w-full py-1"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" - size="large" - onClick={(e: MouseEvent) => { - loadMore() - ;(e.currentTarget as HTMLButtonElement).blur() - }} - > - {language.t("common.loadMore")} - </Button> - </div> - </Show> - </nav> - </div> - ) - } - const createWorkspace = async (project: LocalProject) => { if (!layout.sidebar.opened()) { setState("hoverSession", undefined) @@ -2545,7 +1563,7 @@ export default function Layout(props: ParentProps) { .catch((err) => { showToast({ title: language.t("workspace.create.failed.title"), - description: errorMessage(err), + description: errorMessage(err, language.t("common.requestFailed")), }) return undefined }) @@ -2579,6 +1597,65 @@ export default function Layout(props: ParentProps) { layout.mobileSidebar.hide() } + const workspaceSidebarCtx: WorkspaceSidebarContext = { + currentDir, + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + workspaceName, + renameWorkspace, + editorOpen, + openEditor, + closeEditor, + setEditor, + InlineEditor, + isBusy, + workspaceExpanded: (directory, local) => workspaceOpenState(store.workspaceExpanded, directory, local), + setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value), + showResetWorkspaceDialog: (root, directory) => + dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />), + showDeleteWorkspaceDialog: (root, directory) => + dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />), + setScrollContainerRef: (el, mobile) => { + if (!mobile) scrollContainerRef = el + }, + } + + const projectSidebarCtx: ProjectSidebarContext = { + currentDir, + sidebarOpened: () => layout.sidebar.opened(), + sidebarHovering, + hoverProject: () => state.hoverProject, + nav: () => state.nav, + onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), + onProjectMouseLeave: (worktree) => aim.leave(worktree), + onProjectFocus: (worktree) => aim.activate(worktree), + navigateToProject, + openSidebar: () => layout.sidebar.open(), + closeProject, + showEditProjectDialog, + toggleProjectWorkspaces, + workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), + workspaceIds, + workspaceLabel, + sessionProps: { + sidebarExpanded, + sidebarHovering, + nav: () => state.nav, + hoverSession: () => state.hoverSession, + setHoverSession, + clearHoverProjectSoon, + prefetchSession, + archiveSession, + }, + setHoverSession, + } + const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { const projectName = createMemo(() => { const project = panelProps.project @@ -2649,22 +1726,14 @@ export default function Layout(props: ParentProps) { /> <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}> <DropdownMenu.Content class="mt-1"> - <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}> + <DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}> <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> <DropdownMenu.Item data-action="project-workspaces-toggle" data-project={base64Encode(p().worktree)} disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()} - onSelect={() => { - const enabled = layout.sidebar.workspaces(p().worktree)() - if (enabled) { - layout.sidebar.toggleWorkspaces(p().worktree) - return - } - if (p().vcs !== "git") return - layout.sidebar.toggleWorkspaces(p().worktree) - }} + onSelect={() => toggleProjectWorkspaces(p())} > <DropdownMenu.ItemLabel> {layout.sidebar.workspaces(p().worktree)() @@ -2715,7 +1784,7 @@ export default function Layout(props: ParentProps) { </TooltipKeybind> </div> <div class="flex-1 min-h-0"> - <LocalWorkspace project={p()} mobile={panelProps.mobile} /> + <LocalWorkspace ctx={workspaceSidebarCtx} project={p()} mobile={panelProps.mobile} /> </div> </> } @@ -2750,13 +1819,22 @@ export default function Layout(props: ParentProps) { <SortableProvider ids={workspaces()}> <For each={workspaces()}> {(directory) => ( - <SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} /> + <SortableWorkspace + ctx={workspaceSidebarCtx} + directory={directory} + project={p()} + mobile={panelProps.mobile} + /> )} </For> </SortableProvider> </div> <DragOverlay> - <WorkspaceDragOverlay /> + <WorkspaceDragOverlay + sidebarProject={sidebarProject} + activeWorkspace={() => store.activeWorkspace} + workspaceLabel={workspaceLabel} + /> </DragOverlay> </DragDropProvider> </div> @@ -2793,85 +1871,6 @@ export default function Layout(props: ParentProps) { ) } - const SidebarContent = (sidebarProps: { mobile?: boolean }) => { - const expanded = () => sidebarProps.mobile || layout.sidebar.opened() - - return ( - <div class="flex h-full w-full overflow-hidden"> - <div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}> - <div class="flex-1 min-h-0 w-full"> - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragXAxis /> - <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 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> - <Tooltip - placement={sidebarProps.mobile ? "bottom" : "right"} - value={ - <div class="flex items-center gap-2"> - <span>{language.t("command.project.open")}</span> - <Show when={!sidebarProps.mobile}> - <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span> - </Show> - </div> - } - > - <IconButton - icon="plus" - variant="ghost" - size="large" - onClick={chooseProject} - aria-label={language.t("command.project.open")} - /> - </Tooltip> - </div> - <DragOverlay> - <ProjectDragOverlay /> - </DragOverlay> - </DragDropProvider> - </div> - <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> - <TooltipKeybind - placement={sidebarProps.mobile ? "bottom" : "right"} - title={language.t("sidebar.settings")} - keybind={command.keybind("settings.open")} - > - <IconButton - icon="settings-gear" - variant="ghost" - size="large" - onClick={openSettings} - aria-label={language.t("sidebar.settings")} - /> - </TooltipKeybind> - <Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value={language.t("sidebar.help")}> - <IconButton - icon="help" - variant="ghost" - size="large" - onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")} - aria-label={language.t("sidebar.help")} - /> - </Tooltip> - </div> - </div> - - <Show when={expanded()}> - <SidebarPanel project={currentProject()} mobile={sidebarProps.mobile} /> - </Show> - </div> - ) - } - return ( <div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <Titlebar /> @@ -2905,7 +1904,27 @@ export default function Layout(props: ParentProps) { }} > <div class="@container w-full h-full contain-strict"> - <SidebarContent /> + <SidebarContent + opened={() => layout.sidebar.opened()} + aimMove={aim.move} + projects={() => layout.projects.list()} + renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} />} + handleDragStart={handleDragStart} + handleDragEnd={handleDragEnd} + handleDragOver={handleDragOver} + openProjectLabel={language.t("command.project.open")} + openProjectKeybind={() => command.keybind("project.open")} + onOpenProject={chooseProject} + renderProjectOverlay={() => ( + <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} /> + )} + settingsLabel={() => language.t("sidebar.settings")} + settingsKeybind={() => command.keybind("settings.open")} + onOpenSettings={openSettings} + helpLabel={() => language.t("sidebar.help")} + onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + renderPanel={() => <SidebarPanel project={currentProject()} />} + /> </div> <Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed> {(project) => ( @@ -2947,7 +1966,28 @@ export default function Layout(props: ParentProps) { }} onClick={(e) => e.stopPropagation()} > - <SidebarContent mobile /> + <SidebarContent + mobile + opened={() => layout.sidebar.opened()} + aimMove={aim.move} + projects={() => layout.projects.list()} + renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} mobile />} + handleDragStart={handleDragStart} + handleDragEnd={handleDragEnd} + handleDragOver={handleDragOver} + openProjectLabel={language.t("command.project.open")} + openProjectKeybind={() => command.keybind("project.open")} + onOpenProject={chooseProject} + renderProjectOverlay={() => ( + <ProjectDragOverlay projects={() => layout.projects.list()} activeProject={() => store.activeProject} /> + )} + settingsLabel={() => language.t("sidebar.settings")} + settingsKeybind={() => command.keybind("settings.open")} + onOpenSettings={openSettings} + helpLabel={() => language.t("sidebar.help")} + onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} + renderPanel={() => <SidebarPanel project={currentProject()} mobile />} + /> </nav> </div> diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts new file mode 100644 index 000000000..772e6ece6 --- /dev/null +++ b/packages/app/src/pages/layout/deep-links.ts @@ -0,0 +1,26 @@ +export const deepLinkEvent = "opencode:deep-link" + +export const parseDeepLink = (input: string) => { + if (!input.startsWith("opencode://")) return + const url = new URL(input) + if (url.hostname !== "open-project") return + const directory = url.searchParams.get("directory") + if (!directory) return + return directory +} + +export const collectOpenProjectDeepLinks = (urls: string[]) => + urls.map(parseDeepLink).filter((directory): directory is string => !!directory) + +type OpenCodeWindow = Window & { + __OPENCODE__?: { + deepLinks?: string[] + } +} + +export const drainPendingDeepLinks = (target: OpenCodeWindow) => { + const pending = target.__OPENCODE__?.deepLinks ?? [] + if (pending.length === 0) return [] + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] + return pending +} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts new file mode 100644 index 000000000..8a8ea78c7 --- /dev/null +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links" +import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" + +describe("layout deep links", () => { + test("parses open-project deep links", () => { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + }) + + test("ignores non-project deep links", () => { + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("https://example.com")).toBeUndefined() + }) + + test("collects only valid open-project directories", () => { + const result = collectOpenProjectDeepLinks([ + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", + ]) + expect(result).toEqual(["/a", "/c"]) + }) + + test("drains global deep links once", () => { + const target = { + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], + }, + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } + + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual([]) + }) +}) + +describe("layout workspace helpers", () => { + test("normalizes trailing slash in workspace key", () => { + expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") + expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") + }) + + test("keeps local first while preserving known order", () => { + const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) + expect(result).toEqual(["/root", "/c", "/b"]) + }) + + test("extracts draggable id safely", () => { + expect(getDraggableId({ draggable: { id: "x" } })).toBe("x") + expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined() + expect(getDraggableId(null)).toBeUndefined() + }) + + test("formats fallback project display name", () => { + expect(displayName({ worktree: "/tmp/app" })).toBe("app") + expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App") + }) + + test("extracts api error message and fallback", () => { + expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom") + expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") + expect(errorMessage("unknown", "fallback")).toBe("fallback") + }) +}) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts new file mode 100644 index 000000000..4d144f34e --- /dev/null +++ b/packages/app/src/pages/layout/helpers.ts @@ -0,0 +1,65 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type Session } from "@opencode-ai/sdk/v2/client" + +export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +export function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +export const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +export const childMapByParent = (sessions: Session[]) => { + const map = new Map<string, string[]>() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + +export 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 +} + +export const displayName = (project: { name?: string; worktree: string }) => + project.name || getFilename(project.worktree) + +export const errorMessage = (err: unknown, fallback: string) => { + if (err && typeof err === "object" && "data" in err) { + const data = (err as { data?: { message?: string } }).data + if (data?.message) return data.message + } + if (err instanceof Error) return err.message + return fallback +} + +export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => { + if (!existing) return dirs + const keep = existing.filter((d) => d !== local && dirs.includes(d)) + const missing = dirs.filter((d) => d !== local && !existing.includes(d)) + return [local, ...missing, ...keep] +} diff --git a/packages/app/src/pages/layout/inline-editor.tsx b/packages/app/src/pages/layout/inline-editor.tsx new file mode 100644 index 000000000..0bbfe244c --- /dev/null +++ b/packages/app/src/pages/layout/inline-editor.tsx @@ -0,0 +1,113 @@ +import { createStore } from "solid-js/store" +import { Show, type Accessor } from "solid-js" +import { InlineInput } from "@opencode-ai/ui/inline-input" + +export function createInlineEditorController() { + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + } + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key !== "Escape") return + event.preventDefault() + closeEditor() + } + + const InlineEditor = (props: { + id: string + value: Accessor<string> + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + <Show + when={isEditing()} + fallback={ + <span + class={props.displayClass ?? props.class} + onDblClick={handleDblClick} + onPointerDown={stopPropagation} + onMouseDown={stopPropagation} + onClick={stopPropagation} + onTouchStart={stopPropagation} + > + {props.value()} + </span> + } + > + <InlineInput + ref={(el) => { + requestAnimationFrame(() => el.focus()) + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + editorKeyDown(event, props.onSave) + }} + onBlur={closeEditor} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + </Show> + ) + } + + return { + editor, + editorOpen, + editorValue, + openEditor, + closeEditor, + saveEditor, + editorKeyDown, + setEditor, + InlineEditor, + } +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx new file mode 100644 index 000000000..facfbddc7 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -0,0 +1,330 @@ +import { A, useNavigate, useParams } from "@solidjs/router" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" +import { useNotification } from "@/context/notification" +import { base64Encode } from "@opencode-ai/util/encode" +import { Avatar } from "@opencode-ai/ui/avatar" +import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { MessageNav } from "@opencode-ai/ui/message-nav" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/util/path" +import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client" +import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" +import { agentColor } from "@/utils/agent" + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const notification = useNotification() + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + return ( + <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> + <div class="size-full rounded overflow-clip"> + <Avatar + fallback={name()} + src={ + props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override + } + {...getAvatarColors(props.project.icon?.color)} + class="size-full rounded" + classList={{ "badge-mask": unseenCount() > 0 && props.notify }} + /> + </div> + <Show when={unseenCount() > 0 && props.notify}> + <div + classList={{ + "absolute top-px right-px size-1.5 rounded-full z-10": true, + "bg-icon-critical-base": hasError(), + "bg-text-interactive-base": !hasError(), + }} + /> + </Show> + </div> + ) +} + +export type SessionItemProps = { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + children: Map<string, string[]> + sidebarExpanded: Accessor<boolean> + sidebarHovering: Accessor<boolean> + nav: Accessor<HTMLElement | undefined> + hoverSession: Accessor<string | undefined> + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise<void> +} + +export const SessionItem = (props: SessionItemProps): JSX.Element => { + const params = useParams() + const navigate = useNavigate() + const layout = useLayout() + const language = useLanguage() + const notification = useNotification() + const globalSync = useGlobalSync() + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) + const [sessionStore] = globalSync.child(props.session.directory) + const hasPermissions = createMemo(() => { + const permissions = sessionStore.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = sessionStore.session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agentColor(user.agent, agent?.color) + }) + + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + + const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + props.prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) + return text?.text + } + + const item = ( + <A + href={`${props.slug}/session/${props.session.id}`} + class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} + onPointerEnter={scheduleHoverPrefetch} + onPointerLeave={cancelHoverPrefetch} + onMouseEnter={scheduleHoverPrefetch} + onMouseLeave={cancelHoverPrefetch} + onFocus={() => props.prefetchSession(props.session, "high")} + onClick={() => { + props.setHoverSession(undefined) + if (layout.sidebar.opened()) return + props.clearHoverProjectSoon() + }} + > + <div class="flex items-center gap-1 w-full"> + <div + class="shrink-0 size-6 flex items-center justify-center" + style={{ color: tint() ?? "var(--icon-interactive-base)" }} + > + <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> + <Match when={isWorking()}> + <Spinner class="size-[15px]" /> + </Match> + <Match when={hasPermissions()}> + <div class="size-1.5 rounded-full bg-surface-warning-strong" /> + </Match> + <Match when={hasError()}> + <div class="size-1.5 rounded-full bg-text-diff-delete-base" /> + </Match> + <Match when={unseenCount() > 0}> + <div class="size-1.5 rounded-full bg-text-interactive-base" /> + </Match> + </Switch> + </div> + <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> + {props.session.title} + </span> + <Show when={props.session.summary}> + {(summary) => ( + <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> + <DiffChanges changes={summary()} /> + </div> + )} + </Show> + </div> + </A> + ) + + return ( + <div + data-session-id={props.session.id} + class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 + hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" + > + <Show + when={hoverEnabled()} + fallback={ + <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> + {item} + </Tooltip> + } + > + <HoverCard + openDelay={1000} + closeDelay={props.sidebarHovering() ? 600 : 0} + placement="right-start" + gutter={16} + shift={-2} + trigger={item} + mount={!props.mobile ? props.nav() : undefined} + open={props.hoverSession() === props.session.id} + onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} + > + <Show + when={hoverReady()} + fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>} + > + <div class="overflow-y-auto max-h-72 h-full"> + <MessageNav + messages={hoverMessages() ?? []} + current={undefined} + getLabel={messageLabel} + onMessageSelect={(message) => { + if (!isActive()) { + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) + navigate(`${props.slug}/session/${props.session.id}`) + return + } + window.history.replaceState(null, "", `#message-${message.id}`) + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} + size="normal" + class="w-60" + /> + </div> + </Show> + </HoverCard> + </Show> + <div + class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} + classList={{ + "opacity-100 pointer-events-auto": !!props.mobile, + "opacity-0 pointer-events-none": !props.mobile, + "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, + "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, + }} + > + <Tooltip value={language.t("common.archive")} placement="top"> + <IconButton + icon="archive" + variant="ghost" + class="size-6 rounded-md" + aria-label={language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> + </Tooltip> + </div> + </div> + ) +} + +export const NewSessionItem = (props: { + slug: string + mobile?: boolean + dense?: boolean + sidebarExpanded: Accessor<boolean> + clearHoverProjectSoon: () => void + setHoverSession: (id: string | undefined) => void +}): JSX.Element => { + const layout = useLayout() + const language = useLanguage() + const label = language.t("command.session.new") + const tooltip = () => props.mobile || !props.sidebarExpanded() + const item = ( + <A + href={`${props.slug}/session`} + end + class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} + onClick={() => { + props.setHoverSession(undefined) + if (layout.sidebar.opened()) return + props.clearHoverProjectSoon() + }} + > + <div class="flex items-center gap-1 w-full"> + <div class="shrink-0 size-6 flex items-center justify-center"> + <Icon name="plus-small" size="small" class="text-icon-weak" /> + </div> + <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> + {label} + </span> + </div> + </A> + ) + + return ( + <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"> + <Show + when={!tooltip()} + fallback={ + <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}> + {item} + </Tooltip> + } + > + {item} + </Show> + </div> + ) +} + +export const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( + <div class="flex flex-col gap-1"> + <For each={items}> + {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />} + </For> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.test.ts b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts new file mode 100644 index 000000000..75958d49e --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +describe("projectSelected", () => { + test("matches direct worktree", () => { + expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true) + }) + + test("matches sandbox worktree", () => { + expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true) + expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false) + }) +}) + +describe("projectTileActive", () => { + test("menu state always wins", () => { + expect( + projectTileActive({ + menu: true, + preview: false, + open: false, + overlay: false, + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("preview mode uses open state", () => { + expect( + projectTileActive({ + menu: false, + preview: true, + open: true, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(true) + }) + + test("overlay mode uses hovered project", () => { + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/root", + worktree: "/tmp/root", + }), + ).toBe(true) + expect( + projectTileActive({ + menu: false, + preview: false, + open: false, + overlay: true, + hoverProject: "/tmp/other", + worktree: "/tmp/root", + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-project-helpers.ts b/packages/app/src/pages/layout/sidebar-project-helpers.ts new file mode 100644 index 000000000..06d38a3cd --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project-helpers.ts @@ -0,0 +1,11 @@ +export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) => + worktree === currentDir || sandboxes?.includes(currentDir) === true + +export const projectTileActive = (args: { + menu: boolean + preview: boolean + open: boolean + overlay: boolean + hoverProject?: string + worktree: string +}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx new file mode 100644 index 000000000..c91dc987d --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -0,0 +1,283 @@ +import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js" +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { ContextMenu } from "@opencode-ai/ui/context-menu" +import { HoverCard } from "@opencode-ai/ui/hover-card" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { createSortable } from "@thisbeyond/solid-dnd" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items" +import { childMapByParent, displayName, sortedRootSessions } from "./helpers" +import { projectSelected, projectTileActive } from "./sidebar-project-helpers" + +export type ProjectSidebarContext = { + currentDir: Accessor<string> + sidebarOpened: Accessor<boolean> + sidebarHovering: Accessor<boolean> + hoverProject: Accessor<string | undefined> + nav: Accessor<HTMLElement | undefined> + onProjectMouseEnter: (worktree: string, event: MouseEvent) => void + onProjectMouseLeave: (worktree: string) => void + onProjectFocus: (worktree: string) => void + navigateToProject: (directory: string) => void + openSidebar: () => void + closeProject: (directory: string) => void + showEditProjectDialog: (project: LocalProject) => void + toggleProjectWorkspaces: (project: LocalProject) => void + workspacesEnabled: (project: LocalProject) => boolean + workspaceIds: (project: LocalProject) => string[] + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string + sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover"> + setHoverSession: (id: string | undefined) => void +} + +export const ProjectDragOverlay = (props: { + projects: Accessor<LocalProject[]> + activeProject: Accessor<string | undefined> +}): JSX.Element => { + const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject())) + return ( + <Show when={project()}> + {(p) => ( + <div class="bg-background-base rounded-xl p-1"> + <ProjectIcon project={p()} /> + </div> + )} + </Show> + ) +} + +export const SortableProject = (props: { + project: LocalProject + mobile?: boolean + ctx: ProjectSidebarContext +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => + projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes), + ) + const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) + const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) + + const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) + const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) + const active = createMemo(() => + projectTileActive({ + menu: menu(), + preview: preview(), + open: open(), + overlay: overlay(), + hoverProject: props.ctx.hoverProject(), + worktree: props.project.worktree, + }), + ) + + createEffect(() => { + if (preview()) return + if (!open()) return + setOpen(false) + }) + + const label = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return sortedRootSessions(data, Date.now()).slice(0, 2) + } + const workspaceChildren = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return childMapByParent(data.session) + } + + const Trigger = () => ( + <ContextMenu + modal={!props.ctx.sidebarHovering()} + onOpenChange={(value) => { + setMenu(value) + if (value) setOpen(false) + }} + > + <ContextMenu.Trigger + as="button" + type="button" + aria-label={displayName(props.project)} + data-action="project-switch" + data-project={base64Encode(props.project.worktree)} + classList={{ + "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, + "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), + "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": + !selected() && !active(), + "bg-surface-base-hover border border-border-weak-base": !selected() && active(), + }} + onMouseEnter={(event: MouseEvent) => { + if (!overlay()) return + props.ctx.onProjectMouseEnter(props.project.worktree, event) + }} + onMouseLeave={() => { + if (!overlay()) return + props.ctx.onProjectMouseLeave(props.project.worktree) + }} + onFocus={() => { + if (!overlay()) return + props.ctx.onProjectFocus(props.project.worktree) + }} + onClick={() => props.ctx.navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + <ProjectIcon project={props.project} notify /> + </ContextMenu.Trigger> + <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}> + <ContextMenu.Content> + <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}> + <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel> + </ContextMenu.Item> + <ContextMenu.Item + data-action="project-workspaces-toggle" + data-project={base64Encode(props.project.worktree)} + disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)} + onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)} + > + <ContextMenu.ItemLabel> + {props.ctx.workspacesEnabled(props.project) + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + </ContextMenu.ItemLabel> + </ContextMenu.Item> + <ContextMenu.Separator /> + <ContextMenu.Item + data-action="project-close-menu" + data-project={base64Encode(props.project.worktree)} + onSelect={() => props.ctx.closeProject(props.project.worktree)} + > + <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel> + </ContextMenu.Item> + </ContextMenu.Content> + </ContextMenu.Portal> + </ContextMenu> + ) + + return ( + // @ts-ignore + <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> + <Show when={preview()} fallback={<Trigger />}> + <HoverCard + open={open() && !menu()} + openDelay={0} + closeDelay={0} + placement="right-start" + gutter={6} + trigger={<Trigger />} + onOpenChange={(value) => { + if (menu()) return + setOpen(value) + if (value) props.ctx.setHoverSession(undefined) + }} + > + <div class="-m-3 p-2 flex flex-col w-72"> + <div class="px-4 pt-2 pb-1 flex items-center gap-2"> + <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div> + <Tooltip value={language.t("common.close")} placement="top" gutter={6}> + <IconButton + icon="circle-x" + variant="ghost" + class="shrink-0" + data-action="project-close-hover" + data-project={base64Encode(props.project.worktree)} + aria-label={language.t("common.close")} + onClick={(event) => { + event.stopPropagation() + setOpen(false) + props.ctx.closeProject(props.project.worktree) + }} + /> + </Tooltip> + </div> + <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div> + <div class="px-2 pb-2 flex flex-col gap-2"> + <Show + when={workspaceEnabled()} + fallback={ + <For each={projectSessions()}> + {(session) => ( + <SessionItem + {...props.ctx.sessionProps} + session={session} + slug={base64Encode(props.project.worktree)} + dense + mobile={props.mobile} + popover={false} + children={projectChildren()} + /> + )} + </For> + } + > + <For each={workspaces()}> + {(directory) => { + const sessions = createMemo(() => workspaceSessions(directory)) + const children = createMemo(() => workspaceChildren(directory)) + return ( + <div class="flex flex-col gap-1"> + <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> + <div class="shrink-0 size-6 flex items-center justify-center"> + <Icon name="branch" size="small" class="text-icon-base" /> + </div> + <span class="truncate text-14-medium text-text-base">{label(directory)}</span> + </div> + <For each={sessions()}> + {(session) => ( + <SessionItem + {...props.ctx.sessionProps} + session={session} + slug={base64Encode(directory)} + dense + mobile={props.mobile} + popover={false} + children={children()} + /> + )} + </For> + </div> + ) + }} + </For> + </Show> + </div> + <div class="px-2 py-2 border-t border-border-weak-base"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" + onClick={() => { + props.ctx.openSidebar() + setOpen(false) + if (selected()) return + props.ctx.navigateToProject(props.project.worktree) + }} + > + {language.t("sidebar.project.viewAllSessions")} + </Button> + </div> + </div> + </HoverCard> + </Show> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-shell-helpers.ts b/packages/app/src/pages/layout/sidebar-shell-helpers.ts new file mode 100644 index 000000000..93c286c15 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell-helpers.ts @@ -0,0 +1 @@ +export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened diff --git a/packages/app/src/pages/layout/sidebar-shell.test.ts b/packages/app/src/pages/layout/sidebar-shell.test.ts new file mode 100644 index 000000000..694025a65 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +describe("sidebarExpanded", () => { + test("expands on mobile regardless of desktop open state", () => { + expect(sidebarExpanded(true, false)).toBe(true) + }) + + test("follows desktop open state when not mobile", () => { + expect(sidebarExpanded(false, true)).toBe(true) + expect(sidebarExpanded(false, false)).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx new file mode 100644 index 000000000..ce96a09d1 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -0,0 +1,109 @@ +import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + DragOverlay, + SortableProvider, + closestCenter, + type DragEvent, +} from "@thisbeyond/solid-dnd" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { type LocalProject } from "@/context/layout" +import { sidebarExpanded } from "./sidebar-shell-helpers" + +export const SidebarContent = (props: { + mobile?: boolean + opened: Accessor<boolean> + aimMove: (event: MouseEvent) => void + projects: Accessor<LocalProject[]> + renderProject: (project: LocalProject) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void + openProjectLabel: JSX.Element + openProjectKeybind: Accessor<string | undefined> + onOpenProject: () => void + renderProjectOverlay: () => JSX.Element + settingsLabel: Accessor<string> + settingsKeybind: Accessor<string | undefined> + onOpenSettings: () => void + helpLabel: Accessor<string> + onOpenHelp: () => void + renderPanel: () => JSX.Element +}): JSX.Element => { + const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) + + return ( + <div class="flex h-full w-full overflow-hidden"> + <div + class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" + onMouseMove={props.aimMove} + > + <div class="flex-1 min-h-0 w-full"> + <DragDropProvider + onDragStart={props.handleDragStart} + onDragEnd={props.handleDragEnd} + onDragOver={props.handleDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragXAxis /> + <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar"> + <SortableProvider ids={props.projects().map((p) => p.worktree)}> + <For each={props.projects()}>{(project) => props.renderProject(project)}</For> + </SortableProvider> + <Tooltip + placement={props.mobile ? "bottom" : "right"} + value={ + <div class="flex items-center gap-2"> + <span>{props.openProjectLabel}</span> + <Show when={!props.mobile && !!props.openProjectKeybind()}> + <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span> + </Show> + </div> + } + > + <IconButton + icon="plus" + variant="ghost" + size="large" + onClick={props.onOpenProject} + aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined} + /> + </Tooltip> + </div> + <DragOverlay>{props.renderProjectOverlay()}</DragOverlay> + </DragDropProvider> + </div> + <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> + <TooltipKeybind + placement={props.mobile ? "bottom" : "right"} + title={props.settingsLabel()} + keybind={props.settingsKeybind() ?? ""} + > + <IconButton + icon="settings-gear" + variant="ghost" + size="large" + onClick={props.onOpenSettings} + aria-label={props.settingsLabel()} + /> + </TooltipKeybind> + <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}> + <IconButton + icon="help" + variant="ghost" + size="large" + onClick={props.onOpenHelp} + aria-label={props.helpLabel()} + /> + </Tooltip> + </div> + </div> + + <Show when={expanded()}>{props.renderPanel()}</Show> + </div> + ) +} diff --git a/packages/app/src/pages/layout/sidebar-workspace-helpers.ts b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts new file mode 100644 index 000000000..aa7cb480e --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace-helpers.ts @@ -0,0 +1,2 @@ +export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) => + expanded[directory] ?? local diff --git a/packages/app/src/pages/layout/sidebar-workspace.test.ts b/packages/app/src/pages/layout/sidebar-workspace.test.ts new file mode 100644 index 000000000..d71c39fc8 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { workspaceOpenState } from "./sidebar-workspace-helpers" + +describe("workspaceOpenState", () => { + test("defaults to local workspace open", () => { + expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true) + }) + + test("uses persisted expansion state when present", () => { + expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false) + expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx new file mode 100644 index 000000000..11bad84b0 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -0,0 +1,387 @@ +import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { createSortable } from "@thisbeyond/solid-dnd" +import { base64Encode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" +import { Button } from "@opencode-ai/ui/button" +import { Collapsible } from "@opencode-ai/ui/collapsible" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { type Session } from "@opencode-ai/sdk/v2/client" +import { type LocalProject } from "@/context/layout" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" +import { childMapByParent, sortedRootSessions } from "./helpers" + +type InlineEditorComponent = (props: { + id: string + value: Accessor<string> + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean +}) => JSX.Element + +export type WorkspaceSidebarContext = { + currentDir: Accessor<string> + sidebarExpanded: Accessor<boolean> + sidebarHovering: Accessor<boolean> + nav: Accessor<HTMLElement | undefined> + hoverSession: Accessor<string | undefined> + setHoverSession: (id: string | undefined) => void + clearHoverProjectSoon: () => void + prefetchSession: (session: Session, priority?: "high" | "low") => void + archiveSession: (session: Session) => Promise<void> + workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined + renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void + editorOpen: (id: string) => boolean + openEditor: (id: string, value: string) => void + closeEditor: () => void + setEditor: (key: "value", value: string) => void + InlineEditor: InlineEditorComponent + isBusy: (directory: string) => boolean + workspaceExpanded: (directory: string, local: boolean) => boolean + setWorkspaceExpanded: (directory: string, value: boolean) => void + showResetWorkspaceDialog: (root: string, directory: string) => void + showDeleteWorkspaceDialog: (root: string, directory: string) => void + setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void +} + +export const WorkspaceDragOverlay = (props: { + sidebarProject: Accessor<LocalProject | undefined> + activeWorkspace: Accessor<string | undefined> + workspaceLabel: (directory: string, branch?: string, projectId?: string) => string +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const label = createMemo(() => { + const project = props.sidebarProject() + if (!project) return + const directory = props.activeWorkspace() + if (!directory) return + + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + return `${kind} : ${name}` + }) + + return ( + <Show when={label()}> + {(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>} + </Show> + ) +} + +export const SortableWorkspace = (props: { + ctx: WorkspaceSidebarContext + directory: string + project: LocalProject + mobile?: boolean +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) + const local = createMemo(() => props.directory === props.project.worktree) + const active = createMemo(() => props.ctx.currentDir() === props.directory) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) + return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name + }) + const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) + const boot = createMemo(() => open() || active()) + const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const busy = createMemo(() => props.ctx.isBusy(props.directory)) + const wasBusy = createMemo((prev) => prev || busy(), false) + const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`)) + + const openWrapper = (value: boolean) => { + props.ctx.setWorkspaceExpanded(props.directory, value) + if (value) return + if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor() + } + + createEffect(() => { + if (!boot()) return + globalSync.child(props.directory, { bootstrap: true }) + }) + + const header = () => ( + <div class="flex items-center gap-1 min-w-0 flex-1"> + <div class="flex items-center justify-center shrink-0 size-6"> + <Show when={busy()} fallback={<Icon name="branch" size="small" />}> + <Spinner class="size-[15px]" /> + </Show> + </div> + <span class="text-14-medium text-text-base shrink-0"> + {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : + </span> + <Show + when={!local()} + fallback={ + <span class="text-14-medium text-text-base min-w-0 truncate"> + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + </span> + } + > + <props.ctx.InlineEditor + id={`workspace:${props.directory}`} + value={workspaceValue} + onSave={(next) => { + const trimmed = next.trim() + if (!trimmed) return + props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) + props.ctx.setEditor("value", workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + </Show> + <Icon + name={open() ? "chevron-down" : "chevron-right"} + size="small" + class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100" + /> + </div> + ) + + return ( + <div + // @ts-ignore + use:sortable + classList={{ + "opacity-30": sortable.isActiveDraggable, + "opacity-50 pointer-events-none": busy(), + }} + > + <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}> + <div class="px-2 py-1"> + <div + class="group/workspace relative" + data-component="workspace-item" + data-workspace={base64Encode(props.directory)} + > + <div class="flex items-center gap-1"> + <Show + when={workspaceEditActive()} + fallback={ + <Collapsible.Trigger + class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover" + data-action="workspace-toggle" + data-workspace={base64Encode(props.directory)} + > + {header()} + </Collapsible.Trigger> + } + > + <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div> + </Show> + <div + class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity" + classList={{ + "opacity-100 pointer-events-auto": menu.open, + "opacity-0 pointer-events-none": !menu.open, + "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true, + "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true, + }} + > + <DropdownMenu + modal={!props.ctx.sidebarHovering()} + open={menu.open} + onOpenChange={(open) => setMenu("open", open)} + > + <Tooltip value={language.t("common.moreOptions")} placement="top"> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md" + data-action="workspace-menu" + data-workspace={base64Encode(props.directory)} + aria-label={language.t("common.moreOptions")} + /> + </Tooltip> + <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}> + <DropdownMenu.Content + onCloseAutoFocus={(event) => { + if (!menu.pendingRename) return + event.preventDefault() + setMenu("pendingRename", false) + props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue()) + }} + > + <DropdownMenu.Item + disabled={local()} + onSelect={() => { + setMenu("pendingRename", true) + setMenu("open", false) + }} + > + <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={local() || busy()} + onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)} + > + <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item + disabled={local() || busy()} + onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)} + > + <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + </div> + </div> + </div> + + <Collapsible.Content> + <nav class="flex flex-col gap-1 px-2"> + <NewSessionItem + slug={slug()} + mobile={props.mobile} + sidebarExpanded={props.ctx.sidebarExpanded} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + setHoverSession={props.ctx.setHoverSession} + /> + <Show when={loading()}> + <SessionSkeleton /> + </Show> + <For each={sessions()}> + {(session) => ( + <SessionItem + session={session} + slug={slug()} + mobile={props.mobile} + children={children()} + sidebarExpanded={props.ctx.sidebarExpanded} + sidebarHovering={props.ctx.sidebarHovering} + nav={props.ctx.nav} + hoverSession={props.ctx.hoverSession} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + prefetchSession={props.ctx.prefetchSession} + archiveSession={props.ctx.archiveSession} + /> + )} + </For> + <Show when={hasMore()}> + <div class="relative w-full py-1"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" + size="large" + onClick={(e: MouseEvent) => { + loadMore() + ;(e.currentTarget as HTMLButtonElement).blur() + }} + > + {language.t("common.loadMore")} + </Button> + </div> + </Show> + </nav> + </Collapsible.Content> + </Collapsible> + </div> + ) +} + +export const LocalWorkspace = (props: { + ctx: WorkspaceSidebarContext + project: LocalProject + mobile?: boolean +}): JSX.Element => { + const globalSync = useGlobalSync() + const language = useLanguage() + const workspace = createMemo(() => { + const [store, setStore] = globalSync.child(props.project.worktree) + return { store, setStore } + }) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const children = createMemo(() => childMapByParent(workspace().store.session)) + const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) + const loading = createMemo(() => !booted() && sessions().length === 0) + const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) + const loadMore = async () => { + workspace().setStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( + <div + ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + <nav class="flex flex-col gap-1 px-2"> + <Show when={loading()}> + <SessionSkeleton /> + </Show> + <For each={sessions()}> + {(session) => ( + <SessionItem + session={session} + slug={slug()} + mobile={props.mobile} + children={children()} + sidebarExpanded={props.ctx.sidebarExpanded} + sidebarHovering={props.ctx.sidebarHovering} + nav={props.ctx.nav} + hoverSession={props.ctx.hoverSession} + setHoverSession={props.ctx.setHoverSession} + clearHoverProjectSoon={props.ctx.clearHoverProjectSoon} + prefetchSession={props.ctx.prefetchSession} + archiveSession={props.ctx.archiveSession} + /> + )} + </For> + <Show when={hasMore()}> + <div class="relative w-full py-1"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10" + size="large" + onClick={(e: MouseEvent) => { + loadMore() + ;(e.currentTarget as HTMLButtonElement).blur() + }} + > + {language.t("common.loadMore")} + </Button> + </div> + </Show> + </nav> + </div> + ) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 67606e860..a70d4e8a2 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,84 +1,63 @@ -import { - For, - onCleanup, - onMount, - Show, - Match, - Switch, - createMemo, - createEffect, - createSignal, - on, - type JSX, -} from "solid-js" +import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore, produce } from "solid-js/store" -import { PromptInput } from "@/components/prompt-input" import { SessionContextUsage } from "@/components/session-context-usage" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { Select } from "@opencode-ai/ui/select" import { useCodeComponent } from "@opencode-ai/ui/context/code" -import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" -import { SessionTurn } from "@opencode-ai/ui/session-turn" -import { BasicTool } from "@opencode-ai/ui/basic-tool" import { createAutoScroll } from "@opencode-ai/ui/hooks" -import { SessionReview } from "@opencode-ai/ui/session-review" import { Mark } from "@opencode-ai/ui/logo" -import { QuestionDock } from "@/components/question-dock" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" -import { Terminal } from "@/components/terminal" import { checksum, base64Encode } from "@opencode-ai/util/encode" import { findLast } from "@opencode-ai/util/array" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import FileTree from "@/components/file-tree" -import { DialogSelectModel } from "@/components/dialog-select-model" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" -import { DialogFork } from "@/components/dialog-fork" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" -import type { FileDiff } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" -import { useComments, type LineComment } from "@/context/comments" -import { extractPromptFromParts } from "@/utils/prompt" +import { useComments } from "@/context/comments" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" -import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" -import { - SessionHeader, - SessionContextTab, - SortableTab, - FileVisual, - SortableTerminalTab, - NewSessionView, -} from "@/components/session" +import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" -import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" +import { createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers" import { createScrollSpy } from "@/pages/session/scroll-spy" - -type DiffStyle = "unified" | "split" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { + SessionReviewTab, + StickyAddButton, + type DiffStyle, + type SessionReviewTabProps, +} from "@/pages/session/review-tab" +import { TerminalPanel } from "@/pages/session/terminal-panel" +import { terminalTabLabel } from "@/pages/session/terminal-label" +import { MessageTimeline } from "@/pages/session/message-timeline" +import { useSessionCommands } from "@/pages/session/use-session-commands" +import { SessionPromptDock } from "@/pages/session/session-prompt-dock" +import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" +import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" type HandoffSession = { prompt: string @@ -107,155 +86,6 @@ const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => { touch(handoff.session, key, { ...prev, ...patch }) } -interface SessionReviewTabProps { - title?: JSX.Element - empty?: JSX.Element - diffs: () => FileDiff[] - view: () => ReturnType<ReturnType<typeof useLayout>["view"]> - diffStyle: DiffStyle - onDiffStyleChange?: (style: DiffStyle) => void - onViewFile?: (file: string) => void - onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void - comments?: LineComment[] - focusedComment?: { file: string; id: string } | null - onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void - focusedFile?: string - onScrollRef?: (el: HTMLDivElement) => void - classes?: { - root?: string - header?: string - container?: string - } -} - -function StickyAddButton(props: { children: JSX.Element }) { - const [stuck, setStuck] = createSignal(false) - let button: HTMLDivElement | undefined - - createEffect(() => { - const node = button - if (!node) return - - const scroll = node.parentElement - if (!scroll) return - - const handler = () => { - const rect = node.getBoundingClientRect() - const scrollRect = scroll.getBoundingClientRect() - setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) - } - - scroll.addEventListener("scroll", handler, { passive: true }) - const observer = new ResizeObserver(handler) - observer.observe(scroll) - handler() - onCleanup(() => { - scroll.removeEventListener("scroll", handler) - observer.disconnect() - }) - }) - - return ( - <div - ref={button} - class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3" - classList={{ "border-l": stuck() }} - > - {props.children} - </div> - ) -} - -function SessionReviewTab(props: SessionReviewTabProps) { - let scroll: HTMLDivElement | undefined - let frame: number | undefined - let pending: { x: number; y: number } | undefined - - const sdk = useSDK() - - const readFile = async (path: string) => { - return sdk.client.file - .read({ path }) - .then((x) => x.data) - .catch(() => undefined) - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = props.view().scroll("review") - if (!s) return - - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (frame !== undefined) return - - frame = requestAnimationFrame(() => { - frame = undefined - - const next = pending - pending = undefined - if (!next) return - - props.view().setScroll("review", next) - }) - } - - createEffect( - on( - () => props.diffs().length, - () => { - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - onCleanup(() => { - if (frame === undefined) return - cancelAnimationFrame(frame) - }) - - return ( - <SessionReview - title={props.title} - empty={props.empty} - scrollRef={(el) => { - scroll = el - props.onScrollRef?.(el) - restoreScroll() - }} - onScroll={handleScroll} - onDiffRendered={() => requestAnimationFrame(restoreScroll)} - open={props.view().review.open()} - onOpenChange={props.view().review.setOpen} - classes={{ - root: props.classes?.root ?? "pb-40", - header: props.classes?.header ?? "px-6", - container: props.classes?.container ?? "px-6", - }} - diffs={props.diffs()} - diffStyle={props.diffStyle} - onDiffStyleChange={props.onDiffStyleChange} - onViewFile={props.onViewFile} - focusedFile={props.focusedFile} - readFile={readFile} - onLineComment={props.onLineComment} - comments={props.comments} - focusedComment={props.focusedComment} - onFocusedCommentChange={props.onFocusedCommentChange} - /> - ) -} - export default function Page() { const layout = useLayout() const local = useLocal() @@ -820,8 +650,6 @@ export default function Page() { const scrollGestureWindowMs = 250 - let touchGesture: number | undefined - const markScrollGesture = (target?: EventTarget | null) => { const root = scroller if (!root) return @@ -963,396 +791,6 @@ export default function Page() { }) } - const sessionCommands = createMemo(() => [ - { - id: "session.new", - title: language.t("command.session.new"), - category: language.t("command.category.session"), - keybind: "mod+shift+s", - slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), - }, - ]) - - const fileCommands = createMemo(() => [ - { - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - category: language.t("command.category.file"), - keybind: "mod+p", - slash: "open", - onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), - }, - { - id: "tab.close", - title: language.t("command.tab.close"), - category: language.t("command.category.file"), - keybind: "mod+w", - disabled: !tabs().active(), - onSelect: () => { - const active = tabs().active() - if (!active) return - tabs().close(active) - }, - }, - ]) - - const contextCommands = createMemo(() => [ - { - id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), - category: language.t("command.category.context"), - keybind: "mod+shift+l", - disabled: (() => { - const active = tabs().active() - if (!active) return true - const path = file.pathFromTab(active) - if (!path) return true - return file.selectedLines(path) == null - })(), - onSelect: () => { - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) - if (!path) return - - const range = file.selectedLines(path) - if (!range) { - showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), - }) - return - } - - addSelectionToContext(path, selectionFromLines(range)) - }, - }, - ]) - - const viewCommands = createMemo(() => [ - { - id: "terminal.toggle", - title: language.t("command.terminal.toggle"), - description: "", - category: language.t("command.category.view"), - keybind: "ctrl+`", - slash: "terminal", - onSelect: () => view().terminal.toggle(), - }, - { - id: "review.toggle", - title: language.t("command.review.toggle"), - description: "", - category: language.t("command.category.view"), - keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), - }, - { - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - description: "", - category: language.t("command.category.view"), - onSelect: () => { - const opening = !layout.fileTree.opened() - if (opening && !view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.toggle() - }, - }, - { - id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), - category: language.t("command.category.terminal"), - keybind: "ctrl+alt+t", - onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }, - { - id: "steps.toggle", - title: language.t("command.steps.toggle"), - description: language.t("command.steps.toggle.description"), - category: language.t("command.category.view"), - keybind: "mod+e", - slash: "steps", - disabled: !params.id, - onSelect: () => { - const msg = activeMessage() - if (!msg) return - setStore("expanded", msg.id, (open: boolean | undefined) => !open) - }, - }, - ]) - - const messageCommands = createMemo(() => [ - { - id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), - category: language.t("command.category.session"), - keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(-1), - }, - { - id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), - category: language.t("command.category.session"), - keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(1), - }, - ]) - - const agentCommands = createMemo(() => [ - { - id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - category: language.t("command.category.model"), - keybind: "mod+'", - slash: "model", - onSelect: () => dialog.show(() => <DialogSelectModel />), - }, - { - id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - category: language.t("command.category.mcp"), - keybind: "mod+;", - slash: "mcp", - onSelect: () => dialog.show(() => <DialogSelectMcp />), - }, - { - id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - category: language.t("command.category.agent"), - keybind: "mod+.", - slash: "agent", - onSelect: () => local.agent.move(1), - }, - { - id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - category: language.t("command.category.agent"), - keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), - }, - { - id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - category: language.t("command.category.model"), - keybind: "shift+mod+d", - onSelect: () => { - local.model.variant.cycle() - }, - }, - ]) - - const permissionCommands = createMemo(() => [ - { - id: "permissions.autoaccept", - title: - params.id && permission.isAutoAccepting(params.id, sdk.directory) - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), - category: language.t("command.category.permissions"), - keybind: "mod+shift+a", - disabled: !params.id || !permission.permissionsEnabled(), - onSelect: () => { - const sessionID = params.id - if (!sessionID) return - permission.toggleAutoAccept(sessionID, sdk.directory) - showToast({ - title: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: permission.isAutoAccepting(sessionID, sdk.directory) - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), - }) - }, - }, - ]) - - const sessionActionCommands = createMemo(() => [ - { - id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), - category: language.t("command.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 - const message = findLast(userMessages(), (x) => !revert || x.id < revert) - if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) - const parts = sync.data.part[message.id] - if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) - } - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - setActiveMessage(priorMessage) - }, - }, - { - id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), - category: language.t("command.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) { - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - setActiveMessage(lastMsg) - return - } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - setActiveMessage(priorMsg) - }, - }, - { - id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), - category: language.t("command.category.session"), - slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const model = local.model.current() - if (!model) { - showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), - }) - return - } - await sdk.client.session.summarize({ - sessionID, - modelID: model.id, - providerID: model.provider.id, - }) - }, - }, - { - id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), - category: language.t("command.category.session"), - slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => <DialogFork />), - }, - ]) - - const shareCommands = createMemo(() => { - if (sync.data.config.share === "disabled") return [] - return [ - { - id: "session.share", - title: language.t("command.session.share"), - description: language.t("command.session.share.description"), - category: language.t("command.category.session"), - slash: "share", - disabled: !params.id || !!info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => { - navigator.clipboard.writeText(res.data!.share!.url).catch(() => - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }), - ) - }) - .then(() => - showToast({ - title: language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", - }), - ) - }, - }, - { - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - category: language.t("command.category.session"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) - }, - }, - ] - }) - - command.register("session", () => - combineCommandSections([ - sessionCommands(), - fileCommands(), - contextCommands(), - viewCommands(), - messageCommands(), - agentCommands(), - permissionCommands(), - sessionActionCommands(), - shareCommands(), - ]), - ) - const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement as HTMLElement | undefined if (activeElement) { @@ -1465,6 +903,34 @@ export default function Page() { setFileTreeTab("all") } + useSessionCommands({ + command, + dialog, + file, + language, + local, + permission, + prompt, + sdk, + sync, + terminal, + layout, + params, + navigate, + tabs, + view, + info, + status, + userMessages, + visibleUserMessages, + activeMessage, + showAllFiles, + navigateMessageByOffset, + setExpanded: (id, fn) => setStore("expanded", id, fn), + setActiveMessage, + addSelectionToContext, + }) + const openReviewFile = createOpenReviewFile({ showAllFiles, tabForPath: file.tab, @@ -1781,11 +1247,6 @@ export default function Page() { overflowAnchor: "dynamic", }) - const clearMessageHash = () => { - if (!window.location.hash) return - window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) - } - let scrollStateFrame: number | undefined let scrollStateTarget: HTMLDivElement | undefined const scrollSpy = createScrollSpy({ @@ -1975,162 +1436,23 @@ export default function Page() { }, ) - const updateHash = (id: string) => { - window.history.replaceState(null, "", `#${anchor(id)}`) - } - - createEffect( - on(sessionKey, (key) => { - if (!params.id) return - const messageID = layout.pendingMessage.consume(key) - if (!messageID) return - setUi("pendingMessage", messageID) - }), - ) - - const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { - const root = scroller - if (!root) return false - - const a = el.getBoundingClientRect() - const b = root.getBoundingClientRect() - const top = a.top - b.top + root.scrollTop - root.scrollTo({ top, behavior }) - return true - } - - const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - setActiveMessage(message) - - const msgs = visibleUserMessages() - const index = msgs.findIndex((m) => m.id === message.id) - if (index !== -1 && index < store.turnStart) { - setStore("turnStart", index) - scheduleTurnBackfill() - - requestAnimationFrame(() => { - const el = document.getElementById(anchor(message.id)) - if (!el) { - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - scrollToElement(next, behavior) - }) - return - } - scrollToElement(el, behavior) - }) - - updateHash(message.id) - return - } - - const el = document.getElementById(anchor(message.id)) - if (!el) { - updateHash(message.id) - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) - return - } - if (scrollToElement(el, behavior)) { - updateHash(message.id) - return - } - - requestAnimationFrame(() => { - const next = document.getElementById(anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) - updateHash(message.id) - } - - const applyHash = (behavior: ScrollBehavior) => { - const hash = window.location.hash.slice(1) - if (!hash) { - autoScroll.forceScrollToBottom() - - const el = scroller - if (el) scheduleScrollState(el) - return - } - - const match = hash.match(/^message-(.+)$/) - if (match) { - autoScroll.pause() - const msg = visibleUserMessages().find((m) => m.id === match[1]) - if (msg) { - scrollToMessage(msg, behavior) - return - } - - // If we have a message hash but the message isn't loaded/rendered yet, - // don't fall back to "bottom". We'll retry once messages arrive. - return - } - - const target = document.getElementById(hash) - if (target) { - autoScroll.pause() - scrollToElement(target, behavior) - return - } - - autoScroll.forceScrollToBottom() - - const el = scroller - if (el) scheduleScrollState(el) - } - - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - requestAnimationFrame(() => { - applyHash("auto") - }) - }) - - // Retry message navigation once the target message is actually loaded. - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - // dependencies - visibleUserMessages().length - store.turnStart - - const targetId = - ui.pendingMessage ?? - (() => { - const hash = window.location.hash.slice(1) - const match = hash.match(/^message-(.+)$/) - if (!match) return undefined - return match[1] - })() - if (!targetId) return - if (store.messageId === targetId) return - - const msg = visibleUserMessages().find((m) => m.id === targetId) - if (!msg) return - if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) - autoScroll.pause() - requestAnimationFrame(() => scrollToMessage(msg, "auto")) - }) - - createEffect(() => { - const sessionID = params.id - const ready = messagesReady() - if (!sessionID || !ready) return - - const handler = () => requestAnimationFrame(() => applyHash("auto")) - window.addEventListener("hashchange", handler) - onCleanup(() => window.removeEventListener("hashchange", handler)) + const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ + sessionKey, + sessionID: () => params.id, + messagesReady, + visibleUserMessages, + turnStart: () => store.turnStart, + currentMessageId: () => store.messageId, + pendingMessage: () => ui.pendingMessage, + setPendingMessage: (value) => setUi("pendingMessage", value), + setActiveMessage, + setTurnStart: (value) => setStore("turnStart", value), + scheduleTurnBackfill, + autoScroll, + scroller: () => scroller, + anchor, + scheduleScrollState, + consumePendingMessage: layout.pendingMessage.consume, }) createEffect(() => { @@ -2158,20 +1480,17 @@ export default function Page() { if (!terminal.ready()) return language.locale() - const label = (pty: LocalPTY) => { - const title = pty.title - const number = pty.titleNumber - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number - - if (title && !isDefaultTitle) return title - if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) - if (title) return title - return language.t("terminal.title") - } - - touch(handoff.terminal, params.dir!, terminal.all().map(label)) + touch( + handoff.terminal, + params.dir!, + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string, + }), + ), + ) }) createEffect(() => { @@ -2200,34 +1519,14 @@ export default function Page() { <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> <div class="flex-1 min-h-0 flex flex-col md:flex-row"> - {/* Mobile tab bar */} - <Show when={!isDesktop() && params.id}> - <Tabs class="h-auto"> - <Tabs.List> - <Tabs.Trigger - value="session" - class="w-1/2" - classes={{ button: "w-full" }} - onClick={() => setStore("mobileTab", "session")} - > - {language.t("session.tab.session")} - </Tabs.Trigger> - <Tabs.Trigger - value="changes" - class="w-1/2 !border-r-0" - classes={{ button: "w-full" }} - onClick={() => setStore("mobileTab", "changes")} - > - <Switch> - <Match when={hasReview()}> - {language.t("session.review.filesChanged", { count: reviewCount() })} - </Match> - <Match when={true}>{language.t("session.review.change.other")}</Match> - </Switch> - </Tabs.Trigger> - </Tabs.List> - </Tabs> - </Show> + <SessionMobileTabs + open={!isDesktop() && !!params.id} + hasReview={hasReview()} + reviewCount={reviewCount()} + onSession={() => setStore("mobileTab", "session")} + onChanges={() => setStore("mobileTab", "changes")} + t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string} + /> {/* Session panel */} <div @@ -2245,347 +1544,79 @@ export default function Page() { <Switch> <Match when={params.id}> <Show when={activeMessage()}> - <Show - when={!mobileChanges()} - fallback={ - <div class="relative h-full overflow-hidden"> - {reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6", - })} - </div> - } - > - <div class="relative w-full h-full min-w-0"> - <div - class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out" - classList={{ - "opacity-100 translate-y-0 scale-100": ui.scroll.overflow && !ui.scroll.bottom, - "opacity-0 translate-y-2 scale-95 pointer-events-none": - !ui.scroll.overflow || ui.scroll.bottom, - }} - > - <button - class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors" - onClick={resumeScroll} - > - <Icon name="arrow-down-to-line" /> - </button> - </div> - <div - ref={setScrollRef} - onWheel={(e) => { - const root = e.currentTarget - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (!nested || nested === root) { - markScrollGesture(root) - return - } - - if (!(nested instanceof HTMLElement)) { - markScrollGesture(root) - return - } - - const max = nested.scrollHeight - nested.clientHeight - if (max <= 1) { - markScrollGesture(root) - return - } - - const delta = - e.deltaMode === 1 - ? e.deltaY * 40 - : e.deltaMode === 2 - ? e.deltaY * root.clientHeight - : e.deltaY - if (!delta) return - - if (delta < 0) { - if (nested.scrollTop + delta <= 0) markScrollGesture(root) - return - } - - const remaining = max - nested.scrollTop - if (delta > remaining) markScrollGesture(root) - }} - onTouchStart={(e) => { - touchGesture = e.touches[0]?.clientY - }} - onTouchMove={(e) => { - const next = e.touches[0]?.clientY - const prev = touchGesture - touchGesture = next - if (next === undefined || prev === undefined) return - - const delta = prev - next - if (!delta) return - - const root = e.currentTarget - const target = e.target instanceof Element ? e.target : undefined - const nested = target?.closest("[data-scrollable]") - if (!nested || nested === root) { - markScrollGesture(root) - return - } - - if (!(nested instanceof HTMLElement)) { - markScrollGesture(root) - return - } - - const max = nested.scrollHeight - nested.clientHeight - if (max <= 1) { - markScrollGesture(root) - return - } - - if (delta < 0) { - if (nested.scrollTop + delta <= 0) markScrollGesture(root) - return - } - - const remaining = max - nested.scrollTop - if (delta > remaining) markScrollGesture(root) - }} - onTouchEnd={() => { - touchGesture = undefined - }} - onTouchCancel={() => { - touchGesture = undefined - }} - onPointerDown={(e) => { - if (e.target !== e.currentTarget) return - markScrollGesture(e.currentTarget) - }} - onScroll={(e) => { - scheduleScrollState(e.currentTarget) - if (!hasScrollGesture()) return - autoScroll.handleScroll() - markScrollGesture(e.currentTarget) - if (isDesktop()) scrollSpy.onScroll() - }} - onClick={autoScroll.handleInteraction} - class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" - style={{ "--session-title-height": info()?.title || info()?.parentID ? "40px" : "0px" }} - > - <Show when={info()?.title || info()?.parentID}> - <div - classList={{ - "sticky top-0 z-30 bg-background-stronger": true, - "w-full": true, - "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), - }} - > - <div class="h-10 w-full flex items-center justify-between gap-2"> - <div class="flex items-center gap-1 min-w-0 flex-1"> - <Show when={info()?.parentID}> - <IconButton - tabIndex={-1} - icon="arrow-left" - variant="ghost" - onClick={() => { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - </Show> - <Show when={info()?.title || title.editing}> - <Show - when={title.editing} - fallback={ - <h1 - class="text-16-medium text-text-strong truncate min-w-0" - onDblClick={openTitleEditor} - > - {info()?.title} - </h1> - } - > - <InlineInput - ref={(el) => { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-16-medium text-text-strong grow-1 min-w-0" - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={() => closeTitleEditor()} - /> - </Show> - </Show> - </div> - <Show when={params.id}> - {(id) => ( - <div class="shrink-0 flex items-center"> - <DropdownMenu - open={title.menuOpen} - onOpenChange={(open) => setTitle("menuOpen", open)} - > - <Tooltip value={language.t("common.moreOptions")} placement="top"> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="size-6 rounded-md data-[expanded]:bg-surface-base-active" - aria-label={language.t("common.moreOptions")} - /> - </Tooltip> - <DropdownMenu.Portal> - <DropdownMenu.Content - onCloseAutoFocus={(event) => { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - <DropdownMenu.Item - onSelect={() => { - setTitle({ pendingRename: true, menuOpen: false }) - }} - > - <DropdownMenu.ItemLabel> - {language.t("common.rename")} - </DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Item onSelect={() => void archiveSession(id())}> - <DropdownMenu.ItemLabel> - {language.t("common.archive")} - </DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <DropdownMenu.Separator /> - <DropdownMenu.Item - onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} - > - <DropdownMenu.ItemLabel> - {language.t("common.delete")} - </DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> - </div> - )} - </Show> - </div> - </div> - </Show> - - <div - ref={(el) => { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - role="log" - class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" - classList={{ - "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), - "mt-0.5": centered(), - "mt-0": !centered(), - }} - > - <Show when={store.turnStart > 0}> - <div class="w-full flex justify-center"> - <Button - variant="ghost" - size="large" - class="text-12-medium opacity-50" - onClick={() => setStore("turnStart", 0)} - > - {language.t("session.messages.renderEarlier")} - </Button> - </div> - </Show> - <Show when={historyMore()}> - <div class="w-full flex justify-center"> - <Button - variant="ghost" - size="large" - class="text-12-medium opacity-50" - disabled={historyLoading()} - onClick={() => { - const id = params.id - if (!id) return - setStore("turnStart", 0) - sync.session.history.loadMore(id) - }} - > - {historyLoading() - ? language.t("session.messages.loadingEarlier") - : language.t("session.messages.loadEarlier")} - </Button> - </div> - </Show> - <For each={renderedUserMessages()}> - {(message) => { - if (import.meta.env.DEV) { - onMount(() => { - const id = params.id - if (!id) return - navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) - }) - } - - return ( - <div - id={anchor(message.id)} - data-message-id={message.id} - ref={(el) => { - scrollSpy.register(el, message.id) - onCleanup(() => scrollSpy.unregister(message.id)) - }} - classList={{ - "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px]": centered(), - }} - > - <SessionTurn - sessionID={params.id!} - messageID={message.id} - lastUserMessageID={lastUserMessage()?.id} - stepsExpanded={store.expanded[message.id] ?? false} - onStepsExpandedToggle={() => - setStore("expanded", message.id, (open: boolean | undefined) => !open) - } - classes={{ - root: "min-w-0 w-full relative", - content: "flex flex-col justify-between !overflow-visible", - container: "w-full px-4 md:px-6", - }} - /> - </div> - ) - }} - </For> - </div> - </div> - </div> - </Show> + <MessageTimeline + mobileChanges={mobileChanges()} + mobileFallback={reviewContent({ + diffStyle: "unified", + classes: { + root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6", + })} + scroll={ui.scroll} + onResumeScroll={resumeScroll} + setScrollRef={setScrollRef} + onScheduleScrollState={scheduleScrollState} + onAutoScrollHandleScroll={autoScroll.handleScroll} + onMarkScrollGesture={markScrollGesture} + hasScrollGesture={hasScrollGesture} + isDesktop={isDesktop()} + onScrollSpyScroll={scrollSpy.onScroll} + onAutoScrollInteraction={autoScroll.handleInteraction} + showHeader={!!(info()?.title || info()?.parentID)} + centered={centered()} + title={info()?.title} + parentID={info()?.parentID} + openTitleEditor={openTitleEditor} + closeTitleEditor={closeTitleEditor} + saveTitleEditor={saveTitleEditor} + titleRef={(el) => { + titleRef = el + }} + titleState={title} + onTitleDraft={(value) => setTitle("draft", value)} + onTitleMenuOpen={(open) => setTitle("menuOpen", open)} + onTitlePendingRename={(value) => setTitle("pendingRename", value)} + onNavigateParent={() => { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + sessionID={params.id!} + onArchiveSession={(sessionID) => void archiveSession(sessionID)} + onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)} + t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string} + setContentRef={(el) => { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={store.turnStart} + onRenderEarlier={() => setStore("turnStart", 0)} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + const id = params.id + if (!id) return + setStore("turnStart", 0) + sync.session.history.loadMore(id) + }} + renderedUserMessages={renderedUserMessages()} + anchor={anchor} + onRegisterMessage={scrollSpy.register} + onUnregisterMessage={scrollSpy.unregister} + onFirstTurnMount={() => { + const id = params.id + if (!id) return + navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" }) + }} + lastUserMessageID={lastUserMessage()?.id} + expanded={store.expanded} + onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)} + /> </Show> </Match> <Match when={true}> @@ -2610,115 +1641,27 @@ export default function Page() { </Switch> </div> - {/* Prompt input */} - <div - ref={(el) => (promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" - > - <div - classList={{ - "w-full px-4 pointer-events-auto": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), - }} - > - <Show when={questionRequest()} keyed> - {(req) => { - const count = req.questions.length - const subtitle = - count === 0 - ? "" - : `${count} ${language.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` - return ( - <div data-component="tool-part-wrapper" data-question="true" class="mb-3"> - <BasicTool - icon="bubble-5" - locked - defaultOpen - trigger={{ - title: language.t("ui.tool.questions"), - subtitle, - }} - /> - <QuestionDock request={req} /> - </div> - ) - }} - </Show> - - <Show when={permRequest()} keyed> - {(perm) => ( - <div data-component="tool-part-wrapper" data-permission="true" class="mb-3"> - <BasicTool - icon="checklist" - locked - defaultOpen - trigger={{ - title: language.t("notification.permission.title"), - subtitle: - perm.permission === "doom_loop" - ? language.t("settings.permissions.tool.doom_loop.title") - : perm.permission, - }} - > - <Show when={perm.patterns.length > 0}> - <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar"> - <For each={perm.patterns}> - {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} - </For> - </div> - </Show> - <Show when={perm.permission === "doom_loop"}> - <div class="text-12-regular text-text-weak pb-2 px-3"> - {language.t("settings.permissions.tool.doom_loop.description")} - </div> - </Show> - </BasicTool> - <div data-component="permission-prompt"> - <div data-slot="permission-actions"> - <Button variant="ghost" size="small" onClick={() => decide("reject")} disabled={ui.responding}> - {language.t("ui.permission.deny")} - </Button> - <Button - variant="secondary" - size="small" - onClick={() => decide("always")} - disabled={ui.responding} - > - {language.t("ui.permission.allowAlways")} - </Button> - <Button variant="primary" size="small" onClick={() => decide("once")} disabled={ui.responding}> - {language.t("ui.permission.allowOnce")} - </Button> - </div> - </div> - </div> - )} - </Show> - - <Show when={!blocked()}> - <Show - when={prompt.ready()} - fallback={ - <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> - {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")} - </div> - } - > - <PromptInput - ref={(el) => { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - /> - </Show> - </Show> - </div> - </div> + <SessionPromptDock + centered={centered()} + questionRequest={questionRequest} + permissionRequest={permRequest} + blocked={blocked()} + promptReady={prompt.ready()} + handoffPrompt={handoff.session.get(sessionKey())?.prompt} + t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string} + responding={ui.responding} + onDecide={decide} + inputRef={(el) => { + inputRef = el + }} + newSessionWorktree={newSessionWorktree()} + onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} + onSubmit={() => { + comments.clear() + resumeScroll() + }} + setPromptDockRef={(el) => (promptDock = el)} + /> <Show when={isDesktop() && view().reviewPanel.opened()}> <ResizeHandle @@ -2731,925 +1674,62 @@ export default function Page() { </Show> </div> - {/* Desktop side panel - hidden on mobile */} - <Show when={isDesktop() && view().reviewPanel.opened()}> - <aside - id="review-panel" - aria-label={language.t("session.panel.reviewAndFiles")} - class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex" - > - <div class="flex-1 min-w-0 h-full"> - <Show - when={layout.fileTree.opened() && fileTreeTab() === "changes"} - fallback={ - <DragDropProvider - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <Tabs value={activeTab()} onChange={openTab}> - <div class="sticky top-0 shrink-0 flex"> - <Tabs.List - ref={(el: HTMLDivElement) => { - let scrollTimeout: number | undefined - let prevScrollWidth = el.scrollWidth - let prevContextOpen = contextOpen() - - const handler = () => { - if (scrollTimeout !== undefined) clearTimeout(scrollTimeout) - scrollTimeout = window.setTimeout(() => { - const scrollWidth = el.scrollWidth - const clientWidth = el.clientWidth - const currentContextOpen = contextOpen() - - // Only scroll when a tab is added (width increased), not on removal - if (scrollWidth > prevScrollWidth) { - if (!prevContextOpen && currentContextOpen) { - // Context tab was opened, scroll to first - el.scrollTo({ - left: 0, - behavior: "smooth", - }) - } else if (scrollWidth > clientWidth) { - // File tab was added, scroll to rightmost - el.scrollTo({ - left: scrollWidth - clientWidth, - behavior: "smooth", - }) - } - } - // When width decreases (tab removed), don't scroll - let browser handle it naturally - - prevScrollWidth = scrollWidth - prevContextOpen = currentContextOpen - }, 0) - } - - const wheelHandler = (e: WheelEvent) => { - // Enable horizontal scrolling with mouse wheel - if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { - el.scrollLeft += e.deltaY > 0 ? 50 : -50 - e.preventDefault() - } - } - - el.addEventListener("wheel", wheelHandler, { passive: false }) - - const observer = new MutationObserver(handler) - observer.observe(el, { childList: true }) - - onCleanup(() => { - el.removeEventListener("wheel", wheelHandler) - observer.disconnect() - if (scrollTimeout !== undefined) clearTimeout(scrollTimeout) - }) - }} - > - <Show when={reviewTab()}> - <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}> - <div class="flex items-center gap-1.5"> - <div>{language.t("session.tab.review")}</div> - <Show when={hasReview()}> - <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> - {reviewCount()} - </div> - </Show> - </div> - </Tabs.Trigger> - </Show> - <Show when={contextOpen()}> - <Tabs.Trigger - value="context" - closeButton={ - <Tooltip value={language.t("common.closeTab")} placement="bottom"> - <IconButton - icon="close-small" - variant="ghost" - class="h-5 w-5" - onClick={() => tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - </Tooltip> - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > - <div class="flex items-center gap-2"> - <SessionContextUsage variant="indicator" /> - <div>{language.t("session.tab.context")}</div> - </div> - </Tabs.Trigger> - </Show> - <SortableProvider ids={openedTabs()}> - <For each={openedTabs()}> - {(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />} - </For> - </SortableProvider> - <StickyAddButton> - <TooltipKeybind - title={language.t("command.file.open")} - keybind={command.keybind("file.open")} - class="flex items-center" - > - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - onClick={() => - dialog.show(() => <DialogSelectFile mode="files" onOpenFile={() => showAllFiles()} />) - } - aria-label={language.t("command.file.open")} - /> - </TooltipKeybind> - </StickyAddButton> - </Tabs.List> - </div> - - <Show when={reviewTab()}> - <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "review"}>{reviewPanel()}</Show> - </Tabs.Content> - </Show> - - <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "empty"}> - <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> - <Mark class="w-14 opacity-10" /> - <div class="text-14-regular text-text-weak max-w-56"> - {language.t("session.files.selectToOpen")} - </div> - </div> - </div> - </Show> - </Tabs.Content> - - <Show when={contextOpen()}> - <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> - <Show when={activeTab() === "context"}> - <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> - <SessionContextTab - messages={messages} - visibleUserMessages={visibleUserMessages} - view={view} - info={info} - /> - </div> - </Show> - </Tabs.Content> - </Show> - - <Show when={activeFileTab()} keyed> - {(tab) => { - let scroll: HTMLDivElement | undefined - let scrollFrame: number | undefined - let pending: { x: number; y: number } | undefined - let codeScroll: HTMLElement[] = [] - - const path = createMemo(() => file.pathFromTab(tab)) - const state = createMemo(() => { - const p = path() - if (!p) return - return file.get(p) - }) - const contents = createMemo(() => state()?.content?.content ?? "") - const cacheKey = createMemo(() => checksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return ( - c?.encoding === "base64" && - c?.mimeType?.startsWith("image/") && - c?.mimeType !== "image/svg+xml" - ) - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const isBinary = createMemo(() => state()?.content?.type === "binary") - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding !== "base64") return c.content - return decode64(c.content) - }) - - const svgDecodeFailed = createMemo(() => { - if (!isSvg()) return false - const c = state()?.content - if (!c) return false - if (c.encoding !== "base64") return false - return svgContent() === undefined - }) - - const svgToast = { shown: false } - createEffect(() => { - if (!svgDecodeFailed()) return - if (svgToast.shown) return - svgToast.shown = true - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - description: "Invalid base64 content.", - }) - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { - const p = path() - if (!p) return null - if (file.ready()) return file.selectedLines(p) ?? null - return handoff.session.get(sessionKey())?.files[p] ?? null - }) - - let wrap: HTMLDivElement | undefined - - const fileComments = createMemo(() => { - const p = path() - if (!p) return [] - return comments.list(p) - }) - - const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) - - const [note, setNote] = createStore({ - openedComment: null as string | null, - commenting: null as SelectedLineRange | null, - draft: "", - positions: {} as Record<string, number>, - draftTop: undefined as number | undefined, - }) - - const openedComment = () => note.openedComment - const setOpenedComment = ( - value: - | typeof note.openedComment - | ((value: typeof note.openedComment) => typeof note.openedComment), - ) => setNote("openedComment", value) - - const commenting = () => note.commenting - const setCommenting = ( - value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting), - ) => setNote("commenting", value) - - const draft = () => note.draft - const setDraft = ( - value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft), - ) => setNote("draft", value) - - const positions = () => note.positions - const setPositions = ( - value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions), - ) => setNote("positions", value) - - const draftTop = () => note.draftTop - const setDraftTop = ( - value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop), - ) => setNote("draftTop", value) - - const commentLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return `line ${start}` - return `lines ${start}-${end}` - } - - const getRoot = () => { - const el = wrap - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const node = root.querySelector(`[data-line="${line}"]`) - if (!(node instanceof HTMLElement)) return - return node - } - - const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { - const wrapperRect = wrapper.getBoundingClientRect() - const rect = marker.getBoundingClientRect() - return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) - } - - const updateComments = () => { - const el = wrap - const root = getRoot() - if (!el || !root) { - setPositions({}) - setDraftTop(undefined) - return - } - - const next: Record<string, number> = {} - for (const comment of fileComments()) { - const marker = findMarker(root, comment.selection) - if (!marker) continue - next[comment.id] = markerTop(el, marker) - } - - setPositions(next) - - const range = commenting() - if (!range) { - setDraftTop(undefined) - return - } - - const marker = findMarker(root, range) - if (!marker) { - setDraftTop(undefined) - return - } - - setDraftTop(markerTop(el, marker)) - } - - const scheduleComments = () => { - requestAnimationFrame(updateComments) - } - - createEffect(() => { - fileComments() - scheduleComments() - }) - - createEffect(() => { - const range = commenting() - scheduleComments() - if (!range) return - setDraft("") - }) - - createEffect(() => { - const focus = comments.focus() - const p = path() - if (!focus || !p) return - if (focus.file !== p) return - if (activeTab() !== tab) return - - const target = fileComments().find((comment) => comment.id === focus.id) - if (!target) return - - setOpenedComment(target.id) - setCommenting(null) - file.setSelectedLines(p, target.selection) - requestAnimationFrame(() => comments.clearFocus()) - }) - - const renderCode = (source: string, wrapperClass: string) => ( - <div - ref={(el) => { - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > - <Dynamic - component={codeComponent} - file={{ - name: path() ?? "", - contents: source, - cacheKey: cacheKey(), - }} - enableLineSelection - selectedLines={selectedLines()} - commentedLines={commentedLines()} - onRendered={() => { - requestAnimationFrame(restoreScroll) - requestAnimationFrame(scheduleComments) - }} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - if (!range) setCommenting(null) - }} - onLineSelectionEnd={(range: SelectedLineRange | null) => { - if (!range) { - setCommenting(null) - return - } - - setOpenedComment(null) - setCommenting(range) - }} - overflow="scroll" - class="select-text" - /> - <For each={fileComments()}> - {(comment) => ( - <LineCommentView - id={comment.id} - top={positions()[comment.id]} - open={openedComment() === comment.id} - comment={comment.comment} - selection={commentLabel(comment.selection)} - onMouseEnter={() => { - const p = path() - if (!p) return - file.setSelectedLines(p, comment.selection) - }} - onClick={() => { - const p = path() - if (!p) return - setCommenting(null) - setOpenedComment((current) => (current === comment.id ? null : comment.id)) - file.setSelectedLines(p, comment.selection) - }} - /> - )} - </For> - <Show when={commenting()}> - {(range) => ( - <Show when={draftTop() !== undefined}> - <LineCommentEditor - top={draftTop()} - value={draft()} - selection={commentLabel(range())} - onInput={(value) => setDraft(value)} - onCancel={() => setCommenting(null)} - onSubmit={(value) => { - const p = path() - if (!p) return - addCommentToContext({ - file: p, - selection: range(), - comment: value, - origin: "file", - }) - setCommenting(null) - }} - onPopoverFocusOut={(e: FocusEvent) => { - const current = e.currentTarget as HTMLDivElement - const target = e.relatedTarget - if (target instanceof Node && current.contains(target)) return - - setTimeout(() => { - if (!document.activeElement || !current.contains(document.activeElement)) { - setCommenting(null) - } - }, 0) - }} - /> - </Show> - )} - </Show> - </div> - ) - - const getCodeScroll = () => { - const el = scroll - if (!el) return [] - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return [] - - const root = host.shadowRoot - if (!root) return [] - - return Array.from(root.querySelectorAll("[data-code]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, - ) - } - - const queueScrollUpdate = (next: { x: number; y: number }) => { - pending = next - if (scrollFrame !== undefined) return - - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined - - const next = pending - pending = undefined - if (!next) return - - view().setScroll(tab, next) - }) - } - - const handleCodeScroll = (event: Event) => { - const el = scroll - if (!el) return - - const target = event.currentTarget - if (!(target instanceof HTMLElement)) return - - queueScrollUpdate({ - x: target.scrollLeft, - y: el.scrollTop, - }) - } - - const syncCodeScroll = () => { - const next = getCodeScroll() - if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return - - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - codeScroll = next - - for (const item of codeScroll) { - item.addEventListener("scroll", handleCodeScroll) - } - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = view()?.scroll(tab) - if (!s) return - - syncCodeScroll() - - if (codeScroll.length > 0) { - for (const item of codeScroll) { - if (item.scrollLeft !== s.x) item.scrollLeft = s.x - } - } - - if (el.scrollTop !== s.y) el.scrollTop = s.y - - if (codeScroll.length > 0) return - - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - if (codeScroll.length === 0) syncCodeScroll() - - queueScrollUpdate({ - x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - }) - } - - createEffect( - on( - () => state()?.loaded, - (loaded) => { - if (!loaded) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => file.ready(), - (ready) => { - if (!ready) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => tabs().active() === tab, - (active) => { - if (!active) return - if (!state()?.loaded) return - requestAnimationFrame(restoreScroll) - }, - ), - ) - - onCleanup(() => { - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - if (scrollFrame === undefined) return - cancelAnimationFrame(scrollFrame) - }) - - return ( - <Tabs.Content - value={tab} - class="mt-3 relative" - ref={(el: HTMLDivElement) => { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - <Switch> - <Match when={state()?.loaded && isImage()}> - <div class="px-6 py-4 pb-40"> - <img - src={imageDataUrl()} - alt={path()} - class="max-w-full" - onLoad={() => requestAnimationFrame(restoreScroll)} - /> - </div> - </Match> - <Match when={state()?.loaded && isSvg()}> - <div class="flex flex-col gap-4 px-6 py-4"> - {renderCode(svgContent() ?? "", "")} - <Show when={svgPreviewUrl()}> - <div class="flex justify-center pb-40"> - <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> - </div> - </Show> - </div> - </Match> - <Match when={state()?.loaded && isBinary()}> - <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> - <Mark class="w-14 opacity-10" /> - <div class="flex flex-col gap-2 max-w-md"> - <div class="text-14-semibold text-text-strong truncate"> - {path()?.split("/").pop()} - </div> - <div class="text-14-regular text-text-weak"> - {language.t("session.files.binaryContent")} - </div> - </div> - </div> - </Match> - <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> - <Match when={state()?.loading}> - <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> - </Match> - <Match when={state()?.error}> - {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>} - </Match> - </Switch> - </Tabs.Content> - ) - }} - </Show> - </Tabs> - <DragOverlay> - <Show when={store.activeDraggable}> - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab())) - 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={path()}>{(p) => <FileVisual active path={p()} />}</Show> - </div> - ) - }} - </Show> - </DragOverlay> - </DragDropProvider> - } - > - {reviewPanel()} - </Show> - </div> - - <Show when={layout.fileTree.opened()}> - <div - id="file-tree-panel" - class="relative shrink-0 h-full" - style={{ width: `${layout.fileTree.width()}px` }} - > - <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree"> - <Tabs - variant="pill" - value={fileTreeTab()} - onChange={setFileTreeTabValue} - class="h-full" - data-scope="filetree" - > - <Tabs.List> - <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> - {reviewCount()}{" "} - {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} - </Tabs.Trigger> - <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> - {language.t("session.files.all")} - </Tabs.Trigger> - </Tabs.List> - <Tabs.Content value="changes" class="bg-background-base px-3 py-0"> - <Switch> - <Match when={hasReview()}> - <Show - when={diffsReady()} - fallback={ - <div class="px-2 py-2 text-12-regular text-text-weak"> - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} - </div> - } - > - <FileTree - path="" - allowed={diffFiles()} - kinds={kinds()} - draggable={false} - active={tree.activeDiff} - onFileClick={(node) => focusReviewDiff(node.path)} - /> - </Show> - </Match> - <Match when={true}> - <div class="mt-8 text-center text-12-regular text-text-weak"> - {language.t("session.review.noChanges")} - </div> - </Match> - </Switch> - </Tabs.Content> - <Tabs.Content value="all" class="bg-background-base px-3 py-0"> - <FileTree - path="" - modified={diffFiles()} - kinds={kinds()} - onFileClick={(node) => openTab(file.tab(node.path))} - /> - </Tabs.Content> - </Tabs> - </div> - <ResizeHandle - direction="horizontal" - edge="start" - size={layout.fileTree.width()} - min={200} - max={480} - collapseThreshold={160} - onResize={layout.fileTree.resize} - onCollapse={layout.fileTree.close} - /> - </div> - </Show> - </aside> - </Show> + <SessionSidePanel + open={isDesktop() && view().reviewPanel.opened()} + language={language} + layout={layout} + command={command} + dialog={dialog} + file={file} + comments={comments} + sync={sync} + hasReview={hasReview()} + reviewCount={reviewCount()} + reviewTab={reviewTab()} + contextOpen={contextOpen} + openedTabs={openedTabs} + activeTab={activeTab} + activeFileTab={activeFileTab} + tabs={tabs} + openTab={openTab} + showAllFiles={showAllFiles} + reviewPanel={reviewPanel} + messages={messages as () => unknown[]} + visibleUserMessages={visibleUserMessages as () => unknown[]} + view={view} + info={info as () => unknown} + handoffFiles={() => handoff.session.get(sessionKey())?.files} + codeComponent={codeComponent} + addCommentToContext={addCommentToContext} + activeDraggable={() => store.activeDraggable} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + fileTreeTab={fileTreeTab} + setFileTreeTabValue={setFileTreeTabValue} + diffsReady={diffsReady()} + diffFiles={diffFiles()} + kinds={kinds()} + activeDiff={tree.activeDiff} + focusReviewDiff={focusReviewDiff} + /> </div> - <Show when={isDesktop() && view().terminal.opened()}> - <div - id="terminal-panel" - role="region" - aria-label={language.t("terminal.title")} - class="relative w-full flex 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={view().terminal.close} - /> - <Show - when={terminal.ready()} - fallback={ - <div class="flex flex-col h-full pointer-events-none"> - <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> - <For each={handoff.terminal.get(params.dir!) ?? []}> - {(title) => ( - <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> - {title} - </div> - )} - </For> - <div class="flex-1" /> - <div class="text-text-weak pr-2"> - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} - </div> - </div> - <div class="flex-1 flex items-center justify-center text-text-weak"> - {language.t("terminal.loading")} - </div> - </div> - } - > - <DragDropProvider - onDragStart={handleTerminalDragStart} - onDragEnd={handleTerminalDragEnd} - onDragOver={handleTerminalDragOver} - collisionDetector={closestCenter} - > - <DragDropSensors /> - <ConstrainDragYAxis /> - <div class="flex flex-col h-full"> - <Tabs - variant="alt" - value={terminal.active()} - onChange={(id) => { - // Only switch tabs if not in the middle of starting edit mode - terminal.open(id) - }} - class="!h-auto !flex-none" - > - <Tabs.List class="h-10"> - <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}> - <For each={terminal.all()}> - {(pty) => ( - <SortableTerminalTab - terminal={pty} - onClose={() => { - view().terminal.close() - setUi("autoCreated", false) - }} - /> - )} - </For> - </SortableProvider> - <div class="h-full flex items-center justify-center"> - <TooltipKeybind - title={language.t("command.terminal.new")} - keybind={command.keybind("terminal.new")} - class="flex items-center" - > - <IconButton - icon="plus-small" - variant="ghost" - iconSize="large" - onClick={terminal.new} - aria-label={language.t("command.terminal.new")} - /> - </TooltipKeybind> - </div> - </Tabs.List> - </Tabs> - <div class="flex-1 min-h-0 relative"> - <For each={terminal.all()}> - {(pty) => ( - <div - id={`terminal-wrapper-${pty.id}`} - class="absolute inset-0" - style={{ - display: terminal.active() === pty.id ? "block" : "none", - }} - > - <Show when={pty.id} keyed> - <Terminal - pty={pty} - onCleanup={terminal.update} - onConnectError={() => terminal.clone(pty.id)} - /> - </Show> - </div> - )} - </For> - </div> - </div> - <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"> - {(() => { - const title = t().title - const number = t().titleNumber - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = - Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number - - if (title && !isDefaultTitle) return title - if (Number.isFinite(number) && number > 0) - return language.t("terminal.title.numbered", { number }) - if (title) return title - return language.t("terminal.title") - })()} - </div> - )} - </Show> - ) - }} - </Show> - </DragOverlay> - </DragDropProvider> - </Show> - </div> - </Show> + <TerminalPanel + open={isDesktop() && view().terminal.opened()} + height={layout.terminal.height()} + resize={layout.terminal.resize} + close={view().terminal.close} + terminal={terminal} + language={language} + command={command} + handoff={() => handoff.terminal.get(params.dir!) ?? []} + activeTerminalDraggable={() => store.activeTerminalDraggable} + handleTerminalDragStart={handleTerminalDragStart} + handleTerminalDragOver={handleTerminalDragOver} + handleTerminalDragEnd={handleTerminalDragEnd} + onCloseTab={() => setUi("autoCreated", false)} + /> </div> ) } diff --git a/packages/app/src/pages/session/file-tab-scroll.test.ts b/packages/app/src/pages/session/file-tab-scroll.test.ts new file mode 100644 index 000000000..89e0dcc8f --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { nextTabListScrollLeft } from "./file-tab-scroll" + +describe("nextTabListScrollLeft", () => { + test("does not scroll when width shrinks", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 420, + clientWidth: 300, + prevContextOpen: false, + contextOpen: false, + }) + + expect(left).toBeUndefined() + }) + + test("scrolls to start when context tab opens", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 400, + scrollWidth: 500, + clientWidth: 320, + prevContextOpen: false, + contextOpen: true, + }) + + expect(left).toBe(0) + }) + + test("scrolls to right edge for new file tabs", () => { + const left = nextTabListScrollLeft({ + prevScrollWidth: 500, + scrollWidth: 780, + clientWidth: 300, + prevContextOpen: true, + contextOpen: true, + }) + + expect(left).toBe(480) + }) +}) diff --git a/packages/app/src/pages/session/file-tab-scroll.ts b/packages/app/src/pages/session/file-tab-scroll.ts new file mode 100644 index 000000000..b69188d40 --- /dev/null +++ b/packages/app/src/pages/session/file-tab-scroll.ts @@ -0,0 +1,67 @@ +type Input = { + prevScrollWidth: number + scrollWidth: number + clientWidth: number + prevContextOpen: boolean + contextOpen: boolean +} + +export const nextTabListScrollLeft = (input: Input) => { + if (input.scrollWidth <= input.prevScrollWidth) return + if (!input.prevContextOpen && input.contextOpen) return 0 + if (input.scrollWidth <= input.clientWidth) return + return input.scrollWidth - input.clientWidth +} + +export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => { + let frame: number | undefined + let prevScrollWidth = input.el.scrollWidth + let prevContextOpen = input.contextOpen() + + const update = () => { + const scrollWidth = input.el.scrollWidth + const clientWidth = input.el.clientWidth + const contextOpen = input.contextOpen() + const left = nextTabListScrollLeft({ + prevScrollWidth, + scrollWidth, + clientWidth, + prevContextOpen, + contextOpen, + }) + + if (left !== undefined) { + input.el.scrollTo({ + left, + behavior: "smooth", + }) + } + + prevScrollWidth = scrollWidth + prevContextOpen = contextOpen + } + + const schedule = () => { + if (frame !== undefined) cancelAnimationFrame(frame) + frame = requestAnimationFrame(() => { + frame = undefined + update() + }) + } + + const onWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return + input.el.scrollLeft += e.deltaY > 0 ? 50 : -50 + e.preventDefault() + } + + input.el.addEventListener("wheel", onWheel, { passive: false }) + const observer = new MutationObserver(schedule) + observer.observe(input.el, { childList: true }) + + return () => { + input.el.removeEventListener("wheel", onWheel) + observer.disconnect() + if (frame !== undefined) cancelAnimationFrame(frame) + } +} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx new file mode 100644 index 000000000..0c8281a66 --- /dev/null +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -0,0 +1,516 @@ +import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" +import { checksum } from "@opencode-ai/util/encode" +import { decode64 } from "@/utils/base64" +import { showToast } from "@opencode-ai/ui/toast" +import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" +import { Mark } from "@opencode-ai/ui/logo" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useLayout } from "@/context/layout" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useComments } from "@/context/comments" +import { useLanguage } from "@/context/language" + +export function FileTabContent(props: { + tab: string + activeTab: () => string + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + handoffFiles: () => Record<string, SelectedLineRange | null> | undefined + file: ReturnType<typeof useFile> + comments: ReturnType<typeof useComments> + language: ReturnType<typeof useLanguage> + codeComponent: NonNullable<ValidComponent> + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void +}) { + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const path = createMemo(() => props.file.pathFromTab(props.tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return props.file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => checksum(contents())) + const isImage = createMemo(() => { + const c = state()?.content + return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" + }) + const isSvg = createMemo(() => { + const c = state()?.content + return c?.mimeType === "image/svg+xml" + }) + const isBinary = createMemo(() => state()?.content?.type === "binary") + const svgContent = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding !== "base64") return c.content + return decode64(c.content) + }) + + const svgDecodeFailed = createMemo(() => { + if (!isSvg()) return false + const c = state()?.content + if (!c) return false + if (c.encoding !== "base64") return false + return svgContent() === undefined + }) + + const svgToast = { shown: false } + createEffect(() => { + if (!svgDecodeFailed()) return + if (svgToast.shown) return + svgToast.shown = true + showToast({ + variant: "error", + title: props.language.t("toast.file.loadFailed.title"), + description: "Invalid base64 content.", + }) + }) + const svgPreviewUrl = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` + }) + const imageDataUrl = createMemo(() => { + if (!isImage()) return + const c = state()?.content + return `data:${c?.mimeType};base64,${c?.content}` + }) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (props.file.ready()) return props.file.selectedLines(p) ?? null + return props.handoffFiles()?.[p] ?? null + }) + + let wrap: HTMLDivElement | undefined + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return props.comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + draft: "", + positions: {} as Record<string, number>, + draftTop: undefined as number | undefined, + }) + + const openedComment = () => note.openedComment + const setOpenedComment = ( + value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment), + ) => setNote("openedComment", value) + + const commenting = () => note.commenting + const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) => + setNote("commenting", value) + + const draft = () => note.draft + const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) => + setNote("draft", value) + + const positions = () => note.positions + const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) => + setNote("positions", value) + + const draftTop = () => note.draftTop + const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) => + setNote("draftTop", value) + + const commentLabel = (range: SelectedLineRange) => { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return `line ${start}` + return `lines ${start}-${end}` + } + + const getRoot = () => { + const el = wrap + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const node = root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + return node + } + + const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { + const wrapperRect = wrapper.getBoundingClientRect() + const rect = marker.getBoundingClientRect() + return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) + } + + const updateComments = () => { + const el = wrap + const root = getRoot() + if (!el || !root) { + setPositions({}) + setDraftTop(undefined) + return + } + + const next: Record<string, number> = {} + for (const comment of fileComments()) { + const marker = findMarker(root, comment.selection) + if (!marker) continue + next[comment.id] = markerTop(el, marker) + } + + setPositions(next) + + const range = commenting() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleComments = () => { + requestAnimationFrame(updateComments) + } + + createEffect(() => { + fileComments() + scheduleComments() + }) + + createEffect(() => { + const range = commenting() + scheduleComments() + if (!range) return + setDraft("") + }) + + createEffect(() => { + const focus = props.comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (props.activeTab() !== props.tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + setOpenedComment(target.id) + setCommenting(null) + props.file.setSelectedLines(p, target.selection) + requestAnimationFrame(() => props.comments.clearFocus()) + }) + + const getCodeScroll = () => { + const el = scroll + if (!el) return [] + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return [] + + const root = host.shadowRoot + if (!root) return [] + + return Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const out = pending + pending = undefined + if (!out) return + + props.view().setScroll(props.tab, out) + }) + } + + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view()?.scroll(props.tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (codeScroll.length > 0) return + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.tabs().active() === props.tab, + (active) => { + if (!active) return + if (!state()?.loaded) return + requestAnimationFrame(restoreScroll) + }, + ), + ) + + onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + + const renderCode = (source: string, wrapperClass: string) => ( + <div + ref={(el) => { + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + <Dynamic + component={props.codeComponent} + file={{ + name: path() ?? "", + contents: source, + cacheKey: cacheKey(), + }} + enableLineSelection + selectedLines={selectedLines()} + commentedLines={commentedLines()} + onRendered={() => { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(scheduleComments) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + props.file.setSelectedLines(p, range) + if (!range) setCommenting(null) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + setCommenting(null) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + <For each={fileComments()}> + {(comment) => ( + <LineCommentView + id={comment.id} + top={positions()[comment.id]} + open={openedComment() === comment.id} + comment={comment.comment} + selection={commentLabel(comment.selection)} + onMouseEnter={() => { + const p = path() + if (!p) return + props.file.setSelectedLines(p, comment.selection) + }} + onClick={() => { + const p = path() + if (!p) return + setCommenting(null) + setOpenedComment((current) => (current === comment.id ? null : comment.id)) + props.file.setSelectedLines(p, comment.selection) + }} + /> + )} + </For> + <Show when={commenting()}> + {(range) => ( + <Show when={draftTop() !== undefined}> + <LineCommentEditor + top={draftTop()} + value={draft()} + selection={commentLabel(range())} + onInput={(value) => setDraft(value)} + onCancel={() => setCommenting(null)} + onSubmit={(value) => { + const p = path() + if (!p) return + props.addCommentToContext({ + file: p, + selection: range(), + comment: value, + origin: "file", + }) + setCommenting(null) + }} + onPopoverFocusOut={(e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setCommenting(null) + } + }, 0) + }} + /> + </Show> + )} + </Show> + </div> + ) + + return ( + <Tabs.Content + value={props.tab} + class="mt-3 relative" + ref={(el: HTMLDivElement) => { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + <Switch> + <Match when={state()?.loaded && isImage()}> + <div class="px-6 py-4 pb-40"> + <img + src={imageDataUrl()} + alt={path()} + class="max-w-full" + onLoad={() => requestAnimationFrame(restoreScroll)} + /> + </div> + </Match> + <Match when={state()?.loaded && isSvg()}> + <div class="flex flex-col gap-4 px-6 py-4"> + {renderCode(svgContent() ?? "", "")} + <Show when={svgPreviewUrl()}> + <div class="flex justify-center pb-40"> + <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> + </div> + </Show> + </div> + </Match> + <Match when={state()?.loaded && isBinary()}> + <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> + <Mark class="w-14 opacity-10" /> + <div class="flex flex-col gap-2 max-w-md"> + <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div> + <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div> + </div> + </div> + </Match> + <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> + <Match when={state()?.loading}> + <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div> + </Match> + <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match> + </Switch> + </Tabs.Content> + ) +} diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts new file mode 100644 index 000000000..b2af4bb83 --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture" + +describe("normalizeWheelDelta", () => { + test("converts line mode to px", () => { + expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120) + }) + + test("converts page mode to container height", () => { + expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600) + }) + + test("keeps pixel mode unchanged", () => { + expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16) + }) +}) + +describe("shouldMarkBoundaryGesture", () => { + test("marks when nested scroller cannot scroll", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 0, + scrollHeight: 300, + clientHeight: 300, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond top boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: -40, + scrollTop: 10, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("marks when scrolling beyond bottom boundary", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 50, + scrollTop: 580, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(true) + }) + + test("does not mark when nested scroller can consume movement", () => { + expect( + shouldMarkBoundaryGesture({ + delta: 20, + scrollTop: 200, + scrollHeight: 1000, + clientHeight: 400, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts new file mode 100644 index 000000000..731cb1bde --- /dev/null +++ b/packages/app/src/pages/session/message-gesture.ts @@ -0,0 +1,21 @@ +export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => { + if (input.deltaMode === 1) return input.deltaY * 40 + if (input.deltaMode === 2) return input.deltaY * input.rootHeight + return input.deltaY +} + +export const shouldMarkBoundaryGesture = (input: { + delta: number + scrollTop: number + scrollHeight: number + clientHeight: number +}) => { + const max = input.scrollHeight - input.clientHeight + if (max <= 1) return true + if (!input.delta) return false + + if (input.delta < 0) return input.scrollTop + input.delta <= 0 + + const remaining = max - input.scrollTop + return input.delta > remaining +} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx new file mode 100644 index 000000000..f536c7061 --- /dev/null +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -0,0 +1,348 @@ +import { For, onCleanup, onMount, Show, type JSX } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" + +export function MessageTimeline(props: { + mobileChanges: boolean + mobileFallback: JSX.Element + scroll: { overflow: boolean; bottom: boolean } + onResumeScroll: () => void + setScrollRef: (el: HTMLDivElement | undefined) => void + onScheduleScrollState: (el: HTMLDivElement) => void + onAutoScrollHandleScroll: () => void + onMarkScrollGesture: (target?: EventTarget | null) => void + hasScrollGesture: () => boolean + isDesktop: boolean + onScrollSpyScroll: () => void + onAutoScrollInteraction: (event: MouseEvent) => void + showHeader: boolean + centered: boolean + title?: string + parentID?: string + openTitleEditor: () => void + closeTitleEditor: () => void + saveTitleEditor: () => void | Promise<void> + titleRef: (el: HTMLInputElement) => void + titleState: { + draft: string + editing: boolean + saving: boolean + menuOpen: boolean + pendingRename: boolean + } + onTitleDraft: (value: string) => void + onTitleMenuOpen: (open: boolean) => void + onTitlePendingRename: (value: boolean) => void + onNavigateParent: () => void + sessionID: string + onArchiveSession: (sessionID: string) => void + onDeleteSession: (sessionID: string) => void + t: (key: string, vars?: Record<string, string | number | boolean>) => string + setContentRef: (el: HTMLDivElement) => void + turnStart: number + onRenderEarlier: () => void + historyMore: boolean + historyLoading: boolean + onLoadEarlier: () => void + renderedUserMessages: UserMessage[] + anchor: (id: string) => string + onRegisterMessage: (el: HTMLDivElement, id: string) => void + onUnregisterMessage: (id: string) => void + onFirstTurnMount?: () => void + lastUserMessageID?: string + expanded: Record<string, boolean> + onToggleExpanded: (id: string) => void +}) { + let touchGesture: number | undefined + + return ( + <Show + when={!props.mobileChanges} + fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>} + > + <div class="relative w-full h-full min-w-0"> + <div + class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out" + classList={{ + "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom, + "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom, + }} + > + <button + class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors" + onClick={props.onResumeScroll} + > + <Icon name="arrow-down-to-line" /> + </button> + </div> + <div + ref={props.setScrollRef} + onWheel={(e) => { + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + const delta = normalizeWheelDelta({ + deltaY: e.deltaY, + deltaMode: e.deltaMode, + rootHeight: root.clientHeight, + }) + if (!delta) return + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchStart={(e) => { + touchGesture = e.touches[0]?.clientY + }} + onTouchMove={(e) => { + const next = e.touches[0]?.clientY + const prev = touchGesture + touchGesture = next + if (next === undefined || prev === undefined) return + + const delta = prev - next + if (!delta) return + + const root = e.currentTarget + const target = e.target instanceof Element ? e.target : undefined + const nested = target?.closest("[data-scrollable]") + if (!nested || nested === root) { + props.onMarkScrollGesture(root) + return + } + + if (!(nested instanceof HTMLElement)) { + props.onMarkScrollGesture(root) + return + } + + if ( + shouldMarkBoundaryGesture({ + delta, + scrollTop: nested.scrollTop, + scrollHeight: nested.scrollHeight, + clientHeight: nested.clientHeight, + }) + ) { + props.onMarkScrollGesture(root) + } + }} + onTouchEnd={() => { + touchGesture = undefined + }} + onTouchCancel={() => { + touchGesture = undefined + }} + onPointerDown={(e) => { + if (e.target !== e.currentTarget) return + props.onMarkScrollGesture(e.currentTarget) + }} + onScroll={(e) => { + props.onScheduleScrollState(e.currentTarget) + if (!props.hasScrollGesture()) return + props.onAutoScrollHandleScroll() + props.onMarkScrollGesture(e.currentTarget) + if (props.isDesktop) props.onScrollSpyScroll() + }} + onClick={props.onAutoScrollInteraction} + class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" + style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} + > + <Show when={props.showHeader}> + <div + classList={{ + "sticky top-0 z-30 bg-background-stronger": true, + "w-full": true, + "px-4 md:px-6": true, + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, + }} + > + <div class="h-10 w-full flex items-center justify-between gap-2"> + <div class="flex items-center gap-1 min-w-0 flex-1"> + <Show when={props.parentID}> + <IconButton + tabIndex={-1} + icon="arrow-left" + variant="ghost" + onClick={props.onNavigateParent} + aria-label={props.t("common.goBack")} + /> + </Show> + <Show when={props.title || props.titleState.editing}> + <Show + when={props.titleState.editing} + fallback={ + <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}> + {props.title} + </h1> + } + > + <InlineInput + ref={props.titleRef} + value={props.titleState.draft} + disabled={props.titleState.saving} + class="text-16-medium text-text-strong grow-1 min-w-0" + onInput={(event) => props.onTitleDraft(event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void props.saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + props.closeTitleEditor() + } + }} + onBlur={props.closeTitleEditor} + /> + </Show> + </Show> + </div> + <Show when={props.sessionID}> + {(id) => ( + <div class="shrink-0 flex items-center"> + <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}> + <Tooltip value={props.t("common.moreOptions")} placement="top"> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="size-6 rounded-md data-[expanded]:bg-surface-base-active" + aria-label={props.t("common.moreOptions")} + /> + </Tooltip> + <DropdownMenu.Portal> + <DropdownMenu.Content + onCloseAutoFocus={(event) => { + if (!props.titleState.pendingRename) return + event.preventDefault() + props.onTitlePendingRename(false) + props.openTitleEditor() + }} + > + <DropdownMenu.Item + onSelect={() => { + props.onTitlePendingRename(true) + props.onTitleMenuOpen(false) + }} + > + <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}> + <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + <DropdownMenu.Separator /> + <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}> + <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </div> + )} + </Show> + </div> + </div> + </Show> + + <div + ref={props.setContentRef} + role="log" + class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" + classList={{ + "w-full": true, + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, + "mt-0.5": props.centered, + "mt-0": !props.centered, + }} + > + <Show when={props.turnStart > 0}> + <div class="w-full flex justify-center"> + <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}> + {props.t("session.messages.renderEarlier")} + </Button> + </div> + </Show> + <Show when={props.historyMore}> + <div class="w-full flex justify-center"> + <Button + variant="ghost" + size="large" + class="text-12-medium opacity-50" + disabled={props.historyLoading} + onClick={props.onLoadEarlier} + > + {props.historyLoading + ? props.t("session.messages.loadingEarlier") + : props.t("session.messages.loadEarlier")} + </Button> + </div> + </Show> + <For each={props.renderedUserMessages}> + {(message) => { + if (import.meta.env.DEV && props.onFirstTurnMount) { + onMount(() => props.onFirstTurnMount?.()) + } + + return ( + <div + id={props.anchor(message.id)} + data-message-id={message.id} + ref={(el) => { + props.onRegisterMessage(el, message.id) + onCleanup(() => props.onUnregisterMessage(message.id)) + }} + classList={{ + "min-w-0 w-full max-w-full": true, + "md:max-w-200 3xl:max-w-[1200px]": props.centered, + }} + > + <SessionTurn + sessionID={props.sessionID} + messageID={message.id} + lastUserMessageID={props.lastUserMessageID} + stepsExpanded={props.expanded[message.id] ?? false} + onStepsExpandedToggle={() => props.onToggleExpanded(message.id)} + classes={{ + root: "min-w-0 w-full relative", + content: "flex flex-col justify-between !overflow-visible", + container: "w-full px-4 md:px-6", + }} + /> + </div> + ) + }} + </For> + </div> + </div> + </div> + </Show> + ) +} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx new file mode 100644 index 000000000..a4232dd74 --- /dev/null +++ b/packages/app/src/pages/session/review-tab.tsx @@ -0,0 +1,158 @@ +import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js" +import type { FileDiff } from "@opencode-ai/sdk/v2" +import { SessionReview } from "@opencode-ai/ui/session-review" +import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "@/context/sdk" +import { useLayout } from "@/context/layout" +import type { LineComment } from "@/context/comments" + +export type DiffStyle = "unified" | "split" + +export interface SessionReviewTabProps { + title?: JSX.Element + empty?: JSX.Element + diffs: () => FileDiff[] + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + diffStyle: DiffStyle + onDiffStyleChange?: (style: DiffStyle) => void + onViewFile?: (file: string) => void + onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void + focusedFile?: string + onScrollRef?: (el: HTMLDivElement) => void + classes?: { + root?: string + header?: string + container?: string + } +} + +export function StickyAddButton(props: { children: JSX.Element }) { + const [stuck, setStuck] = createSignal(false) + let button: HTMLDivElement | undefined + + createEffect(() => { + const node = button + if (!node) return + + const scroll = node.parentElement + if (!scroll) return + + const handler = () => { + const rect = node.getBoundingClientRect() + const scrollRect = scroll.getBoundingClientRect() + setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth) + } + + scroll.addEventListener("scroll", handler, { passive: true }) + const observer = new ResizeObserver(handler) + observer.observe(scroll) + handler() + onCleanup(() => { + scroll.removeEventListener("scroll", handler) + observer.disconnect() + }) + }) + + return ( + <div + ref={button} + class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3" + classList={{ "border-l": stuck() }} + > + {props.children} + </div> + ) +} + +export function SessionReviewTab(props: SessionReviewTabProps) { + let scroll: HTMLDivElement | undefined + let frame: number | undefined + let pending: { x: number; y: number } | undefined + + const sdk = useSDK() + + const readFile = async (path: string) => { + return sdk.client.file + .read({ path }) + .then((x) => x.data) + .catch(() => undefined) + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = props.view().scroll("review") + if (!s) return + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (frame !== undefined) return + + frame = requestAnimationFrame(() => { + frame = undefined + + const next = pending + pending = undefined + if (!next) return + + props.view().setScroll("review", next) + }) + } + + createEffect( + on( + () => props.diffs().length, + () => { + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (frame === undefined) return + cancelAnimationFrame(frame) + }) + + return ( + <SessionReview + title={props.title} + empty={props.empty} + scrollRef={(el) => { + scroll = el + props.onScrollRef?.(el) + restoreScroll() + }} + onScroll={handleScroll} + onDiffRendered={() => requestAnimationFrame(restoreScroll)} + open={props.view().review.open()} + onOpenChange={props.view().review.setOpen} + classes={{ + root: props.classes?.root ?? "pb-40", + header: props.classes?.header ?? "px-6", + container: props.classes?.container ?? "px-6", + }} + diffs={props.diffs()} + diffStyle={props.diffStyle} + onDiffStyleChange={props.onDiffStyleChange} + onViewFile={props.onViewFile} + focusedFile={props.focusedFile} + readFile={readFile} + onLineComment={props.onLineComment} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} + /> + ) +} diff --git a/packages/app/src/pages/session/session-command-helpers.ts b/packages/app/src/pages/session/session-command-helpers.ts new file mode 100644 index 000000000..b71a7b768 --- /dev/null +++ b/packages/app/src/pages/session/session-command-helpers.ts @@ -0,0 +1,10 @@ +export const canAddSelectionContext = (input: { + active?: string + pathFromTab: (tab: string) => string | undefined + selectedLines: (path: string) => unknown +}) => { + if (!input.active) return false + const path = input.pathFromTab(input.active) + if (!path) return false + return input.selectedLines(path) != null +} diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx new file mode 100644 index 000000000..41f058231 --- /dev/null +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -0,0 +1,36 @@ +import { Match, Show, Switch } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" + +export function SessionMobileTabs(props: { + open: boolean + hasReview: boolean + reviewCount: number + onSession: () => void + onChanges: () => void + t: (key: string, vars?: Record<string, string | number | boolean>) => string +}) { + return ( + <Show when={props.open}> + <Tabs class="h-auto"> + <Tabs.List> + <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}> + {props.t("session.tab.session")} + </Tabs.Trigger> + <Tabs.Trigger + value="changes" + class="w-1/2 !border-r-0" + classes={{ button: "w-full" }} + onClick={props.onChanges} + > + <Switch> + <Match when={props.hasReview}> + {props.t("session.review.filesChanged", { count: props.reviewCount })} + </Match> + <Match when={true}>{props.t("session.review.change.other")}</Match> + </Switch> + </Tabs.Trigger> + </Tabs.List> + </Tabs> + </Show> + ) +} diff --git a/packages/app/src/pages/session/session-prompt-dock.test.ts b/packages/app/src/pages/session/session-prompt-dock.test.ts new file mode 100644 index 000000000..b3a9945d6 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { questionSubtitle } from "./session-prompt-helpers" + +describe("questionSubtitle", () => { + const t = (key: string) => { + if (key === "ui.common.question.one") return "question" + if (key === "ui.common.question.other") return "questions" + return key + } + + test("returns empty for zero", () => { + expect(questionSubtitle(0, t)).toBe("") + }) + + test("uses singular label", () => { + expect(questionSubtitle(1, t)).toBe("1 question") + }) + + test("uses plural label", () => { + expect(questionSubtitle(3, t)).toBe("3 questions") + }) +}) diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx new file mode 100644 index 000000000..697957027 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -0,0 +1,137 @@ +import { For, Show, type ComponentProps } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { BasicTool } from "@opencode-ai/ui/basic-tool" +import { PromptInput } from "@/components/prompt-input" +import { QuestionDock } from "@/components/question-dock" +import { questionSubtitle } from "@/pages/session/session-prompt-helpers" + +const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"] + +export function SessionPromptDock(props: { + centered: boolean + questionRequest: () => { questions: unknown[] } | undefined + permissionRequest: () => { patterns: string[]; permission: string } | undefined + blocked: boolean + promptReady: boolean + handoffPrompt?: string + t: (key: string, vars?: Record<string, string | number | boolean>) => string + responding: boolean + onDecide: (response: "once" | "always" | "reject") => void + inputRef: (el: HTMLDivElement) => void + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + setPromptDockRef: (el: HTMLDivElement) => void +}) { + return ( + <div + ref={props.setPromptDockRef} + class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + > + <div + classList={{ + "w-full px-4 pointer-events-auto": true, + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, + }} + > + <Show when={props.questionRequest()} keyed> + {(req) => { + const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key)) + return ( + <div data-component="tool-part-wrapper" data-question="true" class="mb-3"> + <BasicTool + icon="bubble-5" + locked + defaultOpen + trigger={{ + title: props.t("ui.tool.questions"), + subtitle, + }} + /> + <QuestionDock request={questionDockRequest(req)} /> + </div> + ) + }} + </Show> + + <Show when={props.permissionRequest()} keyed> + {(perm) => ( + <div data-component="tool-part-wrapper" data-permission="true" class="mb-3"> + <BasicTool + icon="checklist" + locked + defaultOpen + trigger={{ + title: props.t("notification.permission.title"), + subtitle: + perm.permission === "doom_loop" + ? props.t("settings.permissions.tool.doom_loop.title") + : perm.permission, + }} + > + <Show when={perm.patterns.length > 0}> + <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar"> + <For each={perm.patterns}> + {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} + </For> + </div> + </Show> + <Show when={perm.permission === "doom_loop"}> + <div class="text-12-regular text-text-weak pb-2 px-3"> + {props.t("settings.permissions.tool.doom_loop.description")} + </div> + </Show> + </BasicTool> + <div data-component="permission-prompt"> + <div data-slot="permission-actions"> + <Button + variant="ghost" + size="small" + onClick={() => props.onDecide("reject")} + disabled={props.responding} + > + {props.t("ui.permission.deny")} + </Button> + <Button + variant="secondary" + size="small" + onClick={() => props.onDecide("always")} + disabled={props.responding} + > + {props.t("ui.permission.allowAlways")} + </Button> + <Button + variant="primary" + size="small" + onClick={() => props.onDecide("once")} + disabled={props.responding} + > + {props.t("ui.permission.allowOnce")} + </Button> + </div> + </div> + </div> + )} + </Show> + + <Show when={!props.blocked}> + <Show + when={props.promptReady} + fallback={ + <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> + {props.handoffPrompt || props.t("prompt.loading")} + </div> + } + > + <PromptInput + ref={props.inputRef} + newSessionWorktree={props.newSessionWorktree} + onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} + onSubmit={props.onSubmit} + /> + </Show> + </Show> + </div> + </div> + ) +} diff --git a/packages/app/src/pages/session/session-prompt-helpers.ts b/packages/app/src/pages/session/session-prompt-helpers.ts new file mode 100644 index 000000000..ac3234c93 --- /dev/null +++ b/packages/app/src/pages/session/session-prompt-helpers.ts @@ -0,0 +1,4 @@ +export const questionSubtitle = (count: number, t: (key: string) => string) => { + if (count === 0) return "" + return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` +} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx new file mode 100644 index 000000000..573680dec --- /dev/null +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -0,0 +1,306 @@ +import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Mark } from "@opencode-ai/ui/logo" +import FileTree from "@/components/file-tree" +import { SessionContextUsage } from "@/components/session-context-usage" +import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { createFileTabListSync } from "@/pages/session/file-tab-scroll" +import { FileTabContent } from "@/pages/session/file-tabs" +import { StickyAddButton } from "@/pages/session/review-tab" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { useComments } from "@/context/comments" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, type SelectedLineRange } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSync } from "@/context/sync" + +export function SessionSidePanel(props: { + open: boolean + language: ReturnType<typeof useLanguage> + layout: ReturnType<typeof useLayout> + command: ReturnType<typeof useCommand> + dialog: ReturnType<typeof useDialog> + file: ReturnType<typeof useFile> + comments: ReturnType<typeof useComments> + sync: ReturnType<typeof useSync> + hasReview: boolean + reviewCount: number + reviewTab: boolean + contextOpen: () => boolean + openedTabs: () => string[] + activeTab: () => string + activeFileTab: () => string | undefined + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + openTab: (value: string) => void + showAllFiles: () => void + reviewPanel: () => JSX.Element + messages: () => unknown[] + visibleUserMessages: () => unknown[] + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + info: () => unknown + handoffFiles: () => Record<string, SelectedLineRange | null> | undefined + codeComponent: NonNullable<ValidComponent> + addCommentToContext: (input: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) => void + activeDraggable: () => string | undefined + onDragStart: (event: unknown) => void + onDragEnd: () => void + onDragOver: (event: DragEvent) => void + fileTreeTab: () => "changes" | "all" + setFileTreeTabValue: (value: string) => void + diffsReady: boolean + diffFiles: string[] + kinds: Map<string, "add" | "del" | "mix"> + activeDiff?: string + focusReviewDiff: (path: string) => void +}) { + return ( + <Show when={props.open}> + <aside + id="review-panel" + aria-label={props.language.t("session.panel.reviewAndFiles")} + class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex" + > + <div class="flex-1 min-w-0 h-full"> + <Show + when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"} + fallback={ + <DragDropProvider + onDragStart={props.onDragStart} + onDragEnd={props.onDragEnd} + onDragOver={props.onDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <Tabs value={props.activeTab()} onChange={props.openTab}> + <div class="sticky top-0 shrink-0 flex"> + <Tabs.List + ref={(el: HTMLDivElement) => { + const stop = createFileTabListSync({ el, contextOpen: props.contextOpen }) + onCleanup(stop) + }} + > + <Show when={props.reviewTab}> + <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}> + <div class="flex items-center gap-1.5"> + <div>{props.language.t("session.tab.review")}</div> + <Show when={props.hasReview}> + <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> + {props.reviewCount} + </div> + </Show> + </div> + </Tabs.Trigger> + </Show> + <Show when={props.contextOpen()}> + <Tabs.Trigger + value="context" + closeButton={ + <Tooltip value={props.language.t("common.closeTab")} placement="bottom"> + <IconButton + icon="close-small" + variant="ghost" + class="h-5 w-5" + onClick={() => props.tabs().close("context")} + aria-label={props.language.t("common.closeTab")} + /> + </Tooltip> + } + hideCloseButton + onMiddleClick={() => props.tabs().close("context")} + > + <div class="flex items-center gap-2"> + <SessionContextUsage variant="indicator" /> + <div>{props.language.t("session.tab.context")}</div> + </div> + </Tabs.Trigger> + </Show> + <SortableProvider ids={props.openedTabs()}> + <For each={props.openedTabs()}> + {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />} + </For> + </SortableProvider> + <StickyAddButton> + <TooltipKeybind + title={props.language.t("command.file.open")} + keybind={props.command.keybind("file.open")} + class="flex items-center" + > + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={() => + props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />) + } + aria-label={props.language.t("command.file.open")} + /> + </TooltipKeybind> + </StickyAddButton> + </Tabs.List> + </div> + + <Show when={props.reviewTab}> + <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show> + </Tabs.Content> + </Show> + + <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={props.activeTab() === "empty"}> + <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> + <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> + <Mark class="w-14 opacity-10" /> + <div class="text-14-regular text-text-weak max-w-56"> + {props.language.t("session.files.selectToOpen")} + </div> + </div> + </div> + </Show> + </Tabs.Content> + + <Show when={props.contextOpen()}> + <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> + <Show when={props.activeTab() === "context"}> + <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> + <SessionContextTab + messages={props.messages as never} + visibleUserMessages={props.visibleUserMessages as never} + view={props.view as never} + info={props.info as never} + /> + </div> + </Show> + </Tabs.Content> + </Show> + + <Show when={props.activeFileTab()} keyed> + {(tab) => ( + <FileTabContent + tab={tab} + activeTab={props.activeTab} + tabs={props.tabs} + view={props.view} + handoffFiles={props.handoffFiles} + file={props.file} + comments={props.comments} + language={props.language} + codeComponent={props.codeComponent} + addCommentToContext={props.addCommentToContext} + /> + )} + </Show> + </Tabs> + <DragOverlay> + <Show when={props.activeDraggable()}> + {(tab) => { + const path = createMemo(() => props.file.pathFromTab(tab())) + 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={path()}>{(p) => <FileVisual active path={p()} />}</Show> + </div> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + } + > + {props.reviewPanel()} + </Show> + </div> + + <Show when={props.layout.fileTree.opened()}> + <div + id="file-tree-panel" + class="relative shrink-0 h-full" + style={{ width: `${props.layout.fileTree.width()}px` }} + > + <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree"> + <Tabs + variant="pill" + value={props.fileTreeTab()} + onChange={props.setFileTreeTabValue} + class="h-full" + data-scope="filetree" + > + <Tabs.List> + <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> + {props.reviewCount}{" "} + {props.language.t( + props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other", + )} + </Tabs.Trigger> + <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> + {props.language.t("session.files.all")} + </Tabs.Trigger> + </Tabs.List> + <Tabs.Content value="changes" class="bg-background-base px-3 py-0"> + <Switch> + <Match when={props.hasReview}> + <Show + when={props.diffsReady} + fallback={ + <div class="px-2 py-2 text-12-regular text-text-weak"> + {props.language.t("common.loading")} + {props.language.t("common.loading.ellipsis")} + </div> + } + > + <FileTree + path="" + allowed={props.diffFiles} + kinds={props.kinds} + draggable={false} + active={props.activeDiff} + onFileClick={(node) => props.focusReviewDiff(node.path)} + /> + </Show> + </Match> + <Match when={true}> + <div class="mt-8 text-center text-12-regular text-text-weak"> + {props.language.t("session.review.noChanges")} + </div> + </Match> + </Switch> + </Tabs.Content> + <Tabs.Content value="all" class="bg-background-base px-3 py-0"> + <FileTree + path="" + modified={props.diffFiles} + kinds={props.kinds} + onFileClick={(node) => props.openTab(props.file.tab(node.path))} + /> + </Tabs.Content> + </Tabs> + </div> + <ResizeHandle + direction="horizontal" + edge="start" + size={props.layout.fileTree.width()} + min={200} + max={480} + collapseThreshold={160} + onResize={props.layout.fileTree.resize} + onCollapse={props.layout.fileTree.close} + /> + </div> + </Show> + </aside> + </Show> + ) +} diff --git a/packages/app/src/pages/session/terminal-label.ts b/packages/app/src/pages/session/terminal-label.ts new file mode 100644 index 000000000..6d336769b --- /dev/null +++ b/packages/app/src/pages/session/terminal-label.ts @@ -0,0 +1,16 @@ +export const terminalTabLabel = (input: { + title?: string + titleNumber?: number + t: (key: string, vars?: Record<string, string | number | boolean>) => string +}) => { + const title = input.title ?? "" + const number = input.titleNumber ?? 0 + const match = title.match(/^Terminal (\d+)$/) + const parsed = match ? Number(match[1]) : undefined + const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + + if (title && !isDefaultTitle) return title + if (number > 0) return input.t("terminal.title.numbered", { number }) + if (title) return title + return input.t("terminal.title") +} diff --git a/packages/app/src/pages/session/terminal-panel.test.ts b/packages/app/src/pages/session/terminal-panel.test.ts new file mode 100644 index 000000000..43eeec32f --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { terminalTabLabel } from "./terminal-label" + +const t = (key: string, vars?: Record<string, string | number | boolean>) => { + if (key === "terminal.title.numbered") return `Terminal ${vars?.number}` + if (key === "terminal.title") return "Terminal" + return key +} + +describe("terminalTabLabel", () => { + test("returns custom title unchanged", () => { + const label = terminalTabLabel({ title: "server", titleNumber: 3, t }) + expect(label).toBe("server") + }) + + test("normalizes default numbered title", () => { + const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t }) + expect(label).toBe("Terminal 2") + }) + + test("falls back to generic title", () => { + const label = terminalTabLabel({ title: "", titleNumber: 0, t }) + expect(label).toBe("Terminal") + }) +}) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx new file mode 100644 index 000000000..09095d689 --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -0,0 +1,169 @@ +import { createMemo, For, Show } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis } from "@/utils/solid-dnd" +import { SortableTerminalTab } from "@/components/session" +import { Terminal } from "@/components/terminal" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { terminalTabLabel } from "@/pages/session/terminal-label" + +export function TerminalPanel(props: { + open: boolean + height: number + resize: (value: number) => void + close: () => void + terminal: ReturnType<typeof useTerminal> + language: ReturnType<typeof useLanguage> + command: ReturnType<typeof useCommand> + handoff: () => string[] + activeTerminalDraggable: () => string | undefined + handleTerminalDragStart: (event: unknown) => void + handleTerminalDragOver: (event: DragEvent) => void + handleTerminalDragEnd: () => void + onCloseTab: () => void +}) { + return ( + <Show when={props.open}> + <div + id="terminal-panel" + role="region" + aria-label={props.language.t("terminal.title")} + class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" + style={{ height: `${props.height}px` }} + > + <ResizeHandle + direction="vertical" + size={props.height} + min={100} + max={window.innerHeight * 0.6} + collapseThreshold={50} + onResize={props.resize} + onCollapse={props.close} + /> + <Show + when={props.terminal.ready()} + fallback={ + <div class="flex flex-col h-full pointer-events-none"> + <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> + <For each={props.handoff()}> + {(title) => ( + <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> + {title} + </div> + )} + </For> + <div class="flex-1" /> + <div class="text-text-weak pr-2"> + {props.language.t("common.loading")} + {props.language.t("common.loading.ellipsis")} + </div> + </div> + <div class="flex-1 flex items-center justify-center text-text-weak"> + {props.language.t("terminal.loading")} + </div> + </div> + } + > + <DragDropProvider + onDragStart={props.handleTerminalDragStart} + onDragEnd={props.handleTerminalDragEnd} + onDragOver={props.handleTerminalDragOver} + collisionDetector={closestCenter} + > + <DragDropSensors /> + <ConstrainDragYAxis /> + <div class="flex flex-col h-full"> + <Tabs + variant="alt" + value={props.terminal.active()} + onChange={(id) => props.terminal.open(id)} + class="!h-auto !flex-none" + > + <Tabs.List class="h-10"> + <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}> + <For each={props.terminal.all()}> + {(pty) => ( + <SortableTerminalTab + terminal={pty} + onClose={() => { + props.close() + props.onCloseTab() + }} + /> + )} + </For> + </SortableProvider> + <div class="h-full flex items-center justify-center"> + <TooltipKeybind + title={props.language.t("command.terminal.new")} + keybind={props.command.keybind("terminal.new")} + class="flex items-center" + > + <IconButton + icon="plus-small" + variant="ghost" + iconSize="large" + onClick={props.terminal.new} + aria-label={props.language.t("command.terminal.new")} + /> + </TooltipKeybind> + </div> + </Tabs.List> + </Tabs> + <div class="flex-1 min-h-0 relative"> + <For each={props.terminal.all()}> + {(pty) => ( + <div + id={`terminal-wrapper-${pty.id}`} + class="absolute inset-0" + style={{ + display: props.terminal.active() === pty.id ? "block" : "none", + }} + > + <Show when={pty.id} keyed> + <Terminal + pty={pty} + onCleanup={props.terminal.update} + onConnectError={() => props.terminal.clone(pty.id)} + /> + </Show> + </div> + )} + </For> + </div> + </div> + <DragOverlay> + <Show when={props.activeTerminalDraggable()}> + {(draggedId) => { + const pty = createMemo(() => props.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"> + {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: props.language.t as ( + key: string, + vars?: Record<string, string | number | boolean>, + ) => string, + })} + </div> + )} + </Show> + ) + }} + </Show> + </DragOverlay> + </DragDropProvider> + </Show> + </div> + </Show> + ) +} diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts new file mode 100644 index 000000000..ada1871e1 --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { canAddSelectionContext } from "./session-command-helpers" + +describe("canAddSelectionContext", () => { + test("returns false without active tab", () => { + expect( + canAddSelectionContext({ + active: undefined, + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false when active tab is not a file", () => { + expect( + canAddSelectionContext({ + active: "context", + pathFromTab: () => undefined, + selectedLines: () => ({ start: 1, end: 1 }), + }), + ).toBe(false) + }) + + test("returns false without selected lines", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => null, + }), + ).toBe(false) + }) + + test("returns true when file and selection exist", () => { + expect( + canAddSelectionContext({ + active: "file://src/a.ts", + pathFromTab: () => "src/a.ts", + selectedLines: () => ({ start: 1, end: 2 }), + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx new file mode 100644 index 000000000..ae845a657 --- /dev/null +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -0,0 +1,439 @@ +import { createMemo } from "solid-js" +import { useNavigate, useParams } from "@solidjs/router" +import { useCommand } from "@/context/command" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useFile, selectionFromLines, type FileSelection } from "@/context/file" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePermission } from "@/context/permission" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useTerminal } from "@/context/terminal" +import { DialogSelectFile } from "@/components/dialog-select-file" +import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogFork } from "@/components/dialog-fork" +import { showToast } from "@opencode-ai/ui/toast" +import { findLast } from "@opencode-ai/util/array" +import { extractPromptFromParts } from "@/utils/prompt" +import { UserMessage } from "@opencode-ai/sdk/v2" +import { combineCommandSections } from "@/pages/session/helpers" +import { canAddSelectionContext } from "@/pages/session/session-command-helpers" + +export const useSessionCommands = (input: { + command: ReturnType<typeof useCommand> + dialog: ReturnType<typeof useDialog> + file: ReturnType<typeof useFile> + language: ReturnType<typeof useLanguage> + local: ReturnType<typeof useLocal> + permission: ReturnType<typeof usePermission> + prompt: ReturnType<typeof usePrompt> + sdk: ReturnType<typeof useSDK> + sync: ReturnType<typeof useSync> + terminal: ReturnType<typeof useTerminal> + layout: ReturnType<typeof useLayout> + params: ReturnType<typeof useParams> + navigate: ReturnType<typeof useNavigate> + tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> + view: () => ReturnType<ReturnType<typeof useLayout>["view"]> + info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined + status: () => { type: string } + userMessages: () => UserMessage[] + visibleUserMessages: () => UserMessage[] + activeMessage: () => UserMessage | undefined + showAllFiles: () => void + navigateMessageByOffset: (offset: number) => void + setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void + setActiveMessage: (message: UserMessage | undefined) => void + addSelectionToContext: (path: string, selection: FileSelection) => void +}) => { + const sessionCommands = createMemo(() => [ + { + id: "session.new", + title: input.language.t("command.session.new"), + category: input.language.t("command.category.session"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => input.navigate(`/${input.params.dir}/session`), + }, + ]) + + const fileCommands = createMemo(() => [ + { + id: "file.open", + title: input.language.t("command.file.open"), + description: input.language.t("palette.search.placeholder"), + category: input.language.t("command.category.file"), + keybind: "mod+p", + slash: "open", + onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />), + }, + { + id: "tab.close", + title: input.language.t("command.tab.close"), + category: input.language.t("command.category.file"), + keybind: "mod+w", + disabled: !input.tabs().active(), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + input.tabs().close(active) + }, + }, + ]) + + const contextCommands = createMemo(() => [ + { + id: "context.addSelection", + title: input.language.t("command.context.addSelection"), + description: input.language.t("command.context.addSelection.description"), + category: input.language.t("command.category.context"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext({ + active: input.tabs().active(), + pathFromTab: input.file.pathFromTab, + selectedLines: input.file.selectedLines, + }), + onSelect: () => { + const active = input.tabs().active() + if (!active) return + const path = input.file.pathFromTab(active) + if (!path) return + + const range = input.file.selectedLines(path) + if (!range) { + showToast({ + title: input.language.t("toast.context.noLineSelection.title"), + description: input.language.t("toast.context.noLineSelection.description"), + }) + return + } + + input.addSelectionToContext(path, selectionFromLines(range)) + }, + }, + ]) + + const viewCommands = createMemo(() => [ + { + id: "terminal.toggle", + title: input.language.t("command.terminal.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => input.view().terminal.toggle(), + }, + { + id: "review.toggle", + title: input.language.t("command.review.toggle"), + description: "", + category: input.language.t("command.category.view"), + keybind: "mod+shift+r", + onSelect: () => input.view().reviewPanel.toggle(), + }, + { + id: "fileTree.toggle", + title: input.language.t("command.fileTree.toggle"), + description: "", + category: input.language.t("command.category.view"), + onSelect: () => { + const opening = !input.layout.fileTree.opened() + if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open() + input.layout.fileTree.toggle() + }, + }, + { + id: "terminal.new", + title: input.language.t("command.terminal.new"), + description: input.language.t("command.terminal.new.description"), + category: input.language.t("command.category.terminal"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (input.terminal.all().length > 0) input.terminal.new() + input.view().terminal.open() + }, + }, + { + id: "steps.toggle", + title: input.language.t("command.steps.toggle"), + description: input.language.t("command.steps.toggle.description"), + category: input.language.t("command.category.view"), + keybind: "mod+e", + slash: "steps", + disabled: !input.params.id, + onSelect: () => { + const msg = input.activeMessage() + if (!msg) return + input.setExpanded(msg.id, (open: boolean | undefined) => !open) + }, + }, + ]) + + const messageCommands = createMemo(() => [ + { + id: "message.previous", + title: input.language.t("command.message.previous"), + description: input.language.t("command.message.previous.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowup", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(-1), + }, + { + id: "message.next", + title: input.language.t("command.message.next"), + description: input.language.t("command.message.next.description"), + category: input.language.t("command.category.session"), + keybind: "mod+arrowdown", + disabled: !input.params.id, + onSelect: () => input.navigateMessageByOffset(1), + }, + ]) + + const agentCommands = createMemo(() => [ + { + id: "model.choose", + title: input.language.t("command.model.choose"), + description: input.language.t("command.model.choose.description"), + category: input.language.t("command.category.model"), + keybind: "mod+'", + slash: "model", + onSelect: () => input.dialog.show(() => <DialogSelectModel />), + }, + { + id: "mcp.toggle", + title: input.language.t("command.mcp.toggle"), + description: input.language.t("command.mcp.toggle.description"), + category: input.language.t("command.category.mcp"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => input.dialog.show(() => <DialogSelectMcp />), + }, + { + id: "agent.cycle", + title: input.language.t("command.agent.cycle"), + description: input.language.t("command.agent.cycle.description"), + category: input.language.t("command.category.agent"), + keybind: "mod+.", + slash: "agent", + onSelect: () => input.local.agent.move(1), + }, + { + id: "agent.cycle.reverse", + title: input.language.t("command.agent.cycle.reverse"), + description: input.language.t("command.agent.cycle.reverse.description"), + category: input.language.t("command.category.agent"), + keybind: "shift+mod+.", + onSelect: () => input.local.agent.move(-1), + }, + { + id: "model.variant.cycle", + title: input.language.t("command.model.variant.cycle"), + description: input.language.t("command.model.variant.cycle.description"), + category: input.language.t("command.category.model"), + keybind: "shift+mod+d", + onSelect: () => { + input.local.model.variant.cycle() + }, + }, + ]) + + const permissionCommands = createMemo(() => [ + { + id: "permissions.autoaccept", + title: + input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory) + ? input.language.t("command.permissions.autoaccept.disable") + : input.language.t("command.permissions.autoaccept.enable"), + category: input.language.t("command.category.permissions"), + keybind: "mod+shift+a", + disabled: !input.params.id || !input.permission.permissionsEnabled(), + onSelect: () => { + const sessionID = input.params.id + if (!sessionID) return + input.permission.toggleAutoAccept(sessionID, input.sdk.directory) + showToast({ + title: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.title") + : input.language.t("toast.permissions.autoaccept.off.title"), + description: input.permission.isAutoAccepting(sessionID, input.sdk.directory) + ? input.language.t("toast.permissions.autoaccept.on.description") + : input.language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }, + ]) + + const sessionActionCommands = createMemo(() => [ + { + id: "session.undo", + title: input.language.t("command.session.undo"), + description: input.language.t("command.session.undo.description"), + category: input.language.t("command.category.session"), + slash: "undo", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + if (input.status()?.type !== "idle") { + await input.sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = input.info()?.revert?.messageID + const message = findLast(input.userMessages(), (x) => !revert || x.id < revert) + if (!message) return + await input.sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = input.sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: input.sdk.directory }) + input.prompt.set(restored) + } + const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id) + input.setActiveMessage(priorMessage) + }, + }, + { + id: "session.redo", + title: input.language.t("command.session.redo"), + description: input.language.t("command.session.redo.description"), + category: input.language.t("command.category.session"), + slash: "redo", + disabled: !input.params.id || !input.info()?.revert?.messageID, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const revertMessageID = input.info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = input.userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await input.sdk.client.session.unrevert({ sessionID }) + input.prompt.reset() + const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID) + input.setActiveMessage(lastMsg) + return + } + await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id) + input.setActiveMessage(priorMsg) + }, + }, + { + id: "session.compact", + title: input.language.t("command.session.compact"), + description: input.language.t("command.session.compact.description"), + category: input.language.t("command.category.session"), + slash: "compact", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = input.params.id + if (!sessionID) return + const model = input.local.model.current() + if (!model) { + showToast({ + title: input.language.t("toast.model.none.title"), + description: input.language.t("toast.model.none.description"), + }) + return + } + await input.sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) + }, + }, + { + id: "session.fork", + title: input.language.t("command.session.fork"), + description: input.language.t("command.session.fork.description"), + category: input.language.t("command.category.session"), + slash: "fork", + disabled: !input.params.id || input.visibleUserMessages().length === 0, + onSelect: () => input.dialog.show(() => <DialogFork />), + }, + ]) + + const shareCommands = createMemo(() => { + if (input.sync.data.config.share === "disabled") return [] + return [ + { + id: "session.share", + title: input.language.t("command.session.share"), + description: input.language.t("command.session.share.description"), + category: input.language.t("command.category.session"), + slash: "share", + disabled: !input.params.id || !!input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .share({ sessionID: input.params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: input.language.t("toast.session.share.copyFailed.title"), + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: input.language.t("command.session.unshare"), + description: input.language.t("command.session.unshare.description"), + category: input.language.t("command.category.session"), + slash: "unshare", + disabled: !input.params.id || !input.info()?.share?.url, + onSelect: async () => { + if (!input.params.id) return + await input.sdk.client.session + .unshare({ sessionID: input.params.id }) + .then(() => + showToast({ + title: input.language.t("toast.session.unshare.success.title"), + description: input.language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: input.language.t("toast.session.unshare.failed.title"), + description: input.language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }, + ] + }) + + input.command.register("session", () => + combineCommandSections([ + sessionCommands(), + fileCommands(), + contextCommands(), + viewCommands(), + messageCommands(), + agentCommands(), + permissionCommands(), + sessionActionCommands(), + shareCommands(), + ]), + ) +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts new file mode 100644 index 000000000..844f5451e --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import { messageIdFromHash } from "./use-session-hash-scroll" + +describe("messageIdFromHash", () => { + test("parses hash with leading #", () => { + expect(messageIdFromHash("#message-abc123")).toBe("abc123") + }) + + test("parses raw hash fragment", () => { + expect(messageIdFromHash("message-42")).toBe("42") + }) + + test("ignores non-message anchors", () => { + expect(messageIdFromHash("#review-panel")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts new file mode 100644 index 000000000..8952bbd98 --- /dev/null +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -0,0 +1,174 @@ +import { createEffect, on, onCleanup } from "solid-js" +import { UserMessage } from "@opencode-ai/sdk/v2" + +export const messageIdFromHash = (hash: string) => { + const value = hash.startsWith("#") ? hash.slice(1) : hash + const match = value.match(/^message-(.+)$/) + if (!match) return + return match[1] +} + +export const useSessionHashScroll = (input: { + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + turnStart: () => number + currentMessageId: () => string | undefined + pendingMessage: () => string | undefined + setPendingMessage: (value: string | undefined) => void + setActiveMessage: (message: UserMessage | undefined) => void + setTurnStart: (value: number) => void + scheduleTurnBackfill: () => void + autoScroll: { pause: () => void; forceScrollToBottom: () => void } + scroller: () => HTMLDivElement | undefined + anchor: (id: string) => string + scheduleScrollState: (el: HTMLDivElement) => void + consumePendingMessage: (key: string) => string | undefined +}) => { + const clearMessageHash = () => { + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + } + + const updateHash = (id: string) => { + window.history.replaceState(null, "", `#${input.anchor(id)}`) + } + + const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { + const root = input.scroller() + if (!root) return false + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + return true + } + + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + input.setActiveMessage(message) + + const msgs = input.visibleUserMessages() + const index = msgs.findIndex((m) => m.id === message.id) + if (index !== -1 && index < input.turnStart()) { + input.setTurnStart(index) + input.scheduleTurnBackfill() + + requestAnimationFrame(() => { + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + scrollToElement(next, behavior) + }) + return + } + scrollToElement(el, behavior) + }) + + updateHash(message.id) + return + } + + const el = document.getElementById(input.anchor(message.id)) + if (!el) { + updateHash(message.id) + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + return + } + if (scrollToElement(el, behavior)) { + updateHash(message.id) + return + } + + requestAnimationFrame(() => { + const next = document.getElementById(input.anchor(message.id)) + if (!next) return + if (!scrollToElement(next, behavior)) return + }) + updateHash(message.id) + } + + const applyHash = (behavior: ScrollBehavior) => { + const hash = window.location.hash.slice(1) + if (!hash) { + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + return + } + + const messageId = messageIdFromHash(hash) + if (messageId) { + input.autoScroll.pause() + const msg = input.visibleUserMessages().find((m) => m.id === messageId) + if (msg) { + scrollToMessage(msg, behavior) + return + } + return + } + + const target = document.getElementById(hash) + if (target) { + input.autoScroll.pause() + scrollToElement(target, behavior) + return + } + + input.autoScroll.forceScrollToBottom() + const el = input.scroller() + if (el) input.scheduleScrollState(el) + } + + createEffect( + on(input.sessionKey, (key) => { + if (!input.sessionID()) return + const messageID = input.consumePendingMessage(key) + if (!messageID) return + input.setPendingMessage(messageID) + }), + ) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + + input.visibleUserMessages().length + input.turnStart() + + const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash) + if (!targetId) return + if (input.currentMessageId() === targetId) return + + const msg = input.visibleUserMessages().find((m) => m.id === targetId) + if (!msg) return + + if (input.pendingMessage() === targetId) input.setPendingMessage(undefined) + input.autoScroll.pause() + requestAnimationFrame(() => scrollToMessage(msg, "auto")) + }) + + createEffect(() => { + if (!input.sessionID() || !input.messagesReady()) return + const handler = () => requestAnimationFrame(() => applyHash("auto")) + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + + return { + clearMessageHash, + scrollToMessage, + applyHash, + } +} |
