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/session | |
| 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/session')
20 files changed, 2610 insertions, 0 deletions
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, + } +} |
