diff options
| author | Adam <[email protected]> | 2026-01-01 15:40:25 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-01 21:03:05 -0600 |
| commit | 260eef2d6687a1cc030f8ae3dda394c64e460d8e (patch) | |
| tree | ac32c79936bcb28012a32da28772e2a0e403667f | |
| parent | 93f1e1afb8850215cca0c0d97f5114c3a3f1c5e0 (diff) | |
| download | opencode-260eef2d6687a1cc030f8ae3dda394c64e460d8e.tar.gz opencode-260eef2d6687a1cc030f8ae3dda394c64e460d8e.zip | |
wip(app): progress
| -rw-r--r-- | packages/app/src/pages/session.tsx | 351 |
1 files changed, 204 insertions, 147 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c6a1c782f..23ded094e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,18 +1,4 @@ -import { - For, - onCleanup, - onMount, - Show, - Match, - Switch, - createMemo, - createEffect, - on, - createRenderEffect, - batch, - createSignal, -} from "solid-js" - +import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js" import { Dynamic } from "solid-js/web" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file" @@ -28,7 +14,6 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { createAutoScroll } from "@opencode-ai/ui/hooks" - import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { SessionReview } from "@opencode-ai/ui/session-review" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" @@ -80,7 +65,6 @@ export default function Page() { const navigate = useNavigate() const sdk = useSDK() const prompt = usePrompt() - const permission = usePermission() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) @@ -140,6 +124,11 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) + const messagesReady = createMemo(() => { + const id = params.id + if (!id) return true + return sync.data.message[id] !== undefined + }) const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -176,11 +165,13 @@ export default function Page() { stepsExpanded: true, mobileStepsExpanded: {} as Record<string, boolean>, messageId: undefined as string | undefined, + mobileTab: "session" as "session" | "review", + ignoreScrollSpy: false, + initialScrollDone: !params.id, }) const activeMessage = createMemo(() => { if (!store.messageId) return lastUserMessage() - // If the stored message is no longer visible (e.g., was reverted), fall back to last visible const found = visibleUserMessages()?.find((m) => m.id === store.messageId) return found ?? lastUserMessage() }) @@ -204,11 +195,12 @@ export default function Page() { if (targetIndex < 0 || targetIndex >= msgs.length) return - setActiveMessage(msgs[targetIndex]) + scrollToMessage(msgs[targetIndex], "auto") } const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const idle = { type: "idle" as const } let inputRef!: HTMLDivElement createEffect(() => { @@ -236,8 +228,6 @@ export default function Page() { ), ) - const idle = { type: "idle" as const } - createEffect( on( () => params.id, @@ -498,14 +488,6 @@ export default function Page() { } } - onMount(() => { - document.addEventListener("keydown", handleKeyDown) - }) - - onCleanup(() => { - document.removeEventListener("keydown", handleKeyDown) - }) - const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -587,27 +569,68 @@ export default function Page() { onUserInteracted: () => setStore("userInteracted", true), }) - // Mobile tab state for Session/Review switching (only affects mobile layout) - const [mobileTab, setMobileTab] = createSignal<"session" | "review">("session") + let scrollContainer: HTMLDivElement | undefined + let initialScrollFrame: number | undefined + let initialScrollTarget: string | undefined + + const cancelInitialScroll = () => { + if (initialScrollFrame === undefined) return + cancelAnimationFrame(initialScrollFrame) + initialScrollFrame = undefined + } + + const ensureInitialScroll = () => { + cancelInitialScroll() + initialScrollFrame = requestAnimationFrame(() => { + initialScrollFrame = undefined + if (!params.id) { + initialScrollTarget = undefined + setStore("initialScrollDone", true) + return + } + const msgs = visibleUserMessages() + if (msgs.length === 0) { + if (!messagesReady()) { + ensureInitialScroll() + return + } + initialScrollTarget = undefined + setStore("initialScrollDone", true) + return + } + const last = msgs[msgs.length - 1] + const el = messageRefs.get(last.id) + if (!el || !scrollContainer) { + ensureInitialScroll() + return + } + scrollToMessage(last, "auto") + initialScrollTarget = last.id + setStore("initialScrollDone", true) + }) + } + + const setScrollRef = (el: HTMLDivElement | undefined) => { + scrollContainer = el + autoScroll.scrollRef(el) + } - // Track message element refs for scrolling const messageRefs = new Map<string, HTMLDivElement>() - const [ignoreScrollSpy, setIgnoreScrollSpy] = createSignal(false) let scrollTimer: number - const scrollToMessage = (message: UserMessage) => { - setIgnoreScrollSpy(true) + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + setStore("ignoreScrollSpy", true) setActiveMessage(message) const el = messageRefs.get(message.id) if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }) + el.scrollIntoView({ behavior, block: "start" }) } window.clearTimeout(scrollTimer) - scrollTimer = window.setTimeout(() => setIgnoreScrollSpy(false), 1000) + scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000) } const handleScrollSpy = (e: Event) => { - if (ignoreScrollSpy()) return + if (store.ignoreScrollSpy) return const container = e.target as HTMLDivElement const scrollTop = container.scrollTop const threshold = 100 @@ -616,7 +639,6 @@ export default function Page() { for (const message of visibleUserMessages()) { const el = messageRefs.get(message.id) if (!el) continue - if (el.offsetTop <= scrollTop + threshold) { activeId = message.id } else { @@ -629,117 +651,69 @@ export default function Page() { } } + createEffect( + on( + () => params.id, + (id) => { + cancelInitialScroll() + messageRefs.clear() + initialScrollTarget = undefined + setStore("initialScrollDone", !id) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const msgs = visibleUserMessages() + const target = msgs.at(-1)?.id + const ready = messagesReady() + + if (!params.id) { + setStore("initialScrollDone", true) + initialScrollTarget = undefined + return + } + + if (!ready) { + setStore("initialScrollDone", false) + ensureInitialScroll() + return + } + + if (!store.initialScrollDone) { + ensureInitialScroll() + return + } + + if (!initialScrollTarget && target) { + setStore("initialScrollDone", false) + ensureInitialScroll() + } + }) + createEffect(() => { const msgs = visibleUserMessages() if (msgs.length === 0) return - // Wait for refs to be populated requestAnimationFrame(() => { - const container = autoScroll.scrollRef - if (!container) return + if (!scrollContainer) return // Manually trigger spy once to set initial active message based on scroll position - handleScrollSpy({ target: container } as unknown as Event) + handleScrollSpy({ target: scrollContainer } as unknown as Event) }) }) - // Unified SessionTurns component - works for both mobile and desktop - const SessionTurns = () => ( - <div class="relative w-full h-full min-w-0"> - {/* Message rail - hidden on mobile, positioned absolutely over the content */} - <div class="hidden md:block absolute inset-0 pointer-events-none z-10"> - <SessionMessageRail - messages={visibleUserMessages()} - current={activeMessage()} - onMessageSelect={scrollToMessage} - wide={!showTabs()} - class="pointer-events-auto" - /> - </div> - <div - ref={autoScroll.scrollRef} - onScroll={(e) => { - autoScroll.handleScroll() - handleScrollSpy(e) - }} - onClick={autoScroll.handleInteraction} - class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar snap-y snap-proximity" - > - <div - ref={autoScroll.contentRef} - class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]" - classList={{ - "mt-0.5": !showTabs(), - "mt-1": showTabs(), - }} - > - <For each={visibleUserMessages()}> - {(message) => ( - <div - ref={(el) => messageRefs.set(message.id, el)} - class="min-w-0 w-full max-w-full snap-start scroll-m-4 last:min-h-[80vh]" - > - <SessionTurn - sessionID={params.id!} - messageID={message.id} - lastUserMessageID={lastUserMessage()?.id} - stepsExpanded={store.mobileStepsExpanded[message.id] ?? false} - onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)} - onUserInteracted={() => setStore("userInteracted", true)} - classes={{ - root: "min-w-0 w-full relative", - content: - "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", - container: - "px-4 md:px-6 " + - (!showTabs() - ? "md:max-w-200 md:mx-auto" - : visibleUserMessages().length > 1 - ? "md:pr-6 md:pl-18" - : ""), - }} - /> - </div> - )} - </For> - </div> - </div> - </div> - ) - - // Session content component - unified for mobile and desktop - const SessionContent = () => ( - <Switch> - <Match when={params.id}> - <SessionTurns /> - </Match> - <Match when={true}> - <NewSessionView /> - </Match> - </Switch> - ) + createEffect(() => { + document.addEventListener("keydown", handleKeyDown) + }) - // Review panel content - used on both mobile (via tabs) and desktop (side panel) - const ReviewPanel = () => ( - <div class="relative h-full overflow-y-auto no-scrollbar"> - <SessionReview - diffs={diffs()} - diffStyle={layout.review.diffStyle()} - onDiffStyleChange={layout.review.setDiffStyle} - open={view().review.open()} - onOpenChange={view().review.setOpen} - classes={{ - root: "pb-32", - header: "px-4", - container: "px-4", - }} - /> - </div> - ) + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + cancelInitialScroll() + }) return ( <div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <SessionHeader /> - - {/* Main content area - responsive layout */} <div class="flex-1 min-h-0 flex flex-col md:flex-row"> {/* Mobile tab bar - only shown on mobile when there are diffs */} <Show when={diffs().length > 0}> @@ -748,10 +722,10 @@ export default function Page() { type="button" class="flex-1 py-3 text-14-medium border-b-2 transition-colors" classList={{ - "border-text-base text-text-base": mobileTab() === "session", - "border-transparent text-text-weak": mobileTab() !== "session", + "border-text-base text-text-base": store.mobileTab === "session", + "border-transparent text-text-weak": store.mobileTab !== "session", }} - onClick={() => setMobileTab("session")} + onClick={() => setStore("mobileTab", "session")} > Session </button> @@ -759,10 +733,10 @@ export default function Page() { type="button" class="flex-1 py-3 text-14-medium border-b-2 transition-colors" classList={{ - "border-text-base text-text-base": mobileTab() === "review", - "border-transparent text-text-weak": mobileTab() !== "review", + "border-text-base text-text-base": store.mobileTab === "review", + "border-transparent text-text-weak": store.mobileTab !== "review", }} - onClick={() => setMobileTab("review")} + onClick={() => setStore("mobileTab", "review")} > {diffs().length} Files Changed </button> @@ -774,13 +748,83 @@ export default function Page() { class="@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger md:py-3" classList={{ // Mobile: hide when review tab is active and there are diffs - "hidden md:flex": diffs().length > 0 && mobileTab() === "review", + "hidden md:flex": diffs().length > 0 && store.mobileTab === "review", "flex-1 md:flex-none": true, }} style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }} > <div class="flex-1 min-h-0 overflow-hidden"> - <SessionContent /> + <Show when={activeMessage()}> + <Switch> + <Match when={params.id}> + <div class="relative w-full h-full min-w-0"> + <div class="hidden md:block absolute inset-0 pointer-events-none z-10"> + <SessionMessageRail + messages={visibleUserMessages()} + current={activeMessage()} + onMessageSelect={scrollToMessage} + wide={!showTabs()} + class="pointer-events-auto" + /> + </div> + <div + ref={setScrollRef} + onScroll={(e) => { + autoScroll.handleScroll() + handleScrollSpy(e) + }} + onClick={autoScroll.handleInteraction} + class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar" + classList={{ + "opacity-0 pointer-events-none": !store.initialScrollDone, + }} + > + <div + ref={autoScroll.contentRef} + class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]" + classList={{ + "mt-0.5": !showTabs(), + "mt-0": showTabs(), + }} + > + <For each={visibleUserMessages()}> + {(message) => ( + <div + ref={(el) => messageRefs.set(message.id, el)} + class="min-w-0 w-full max-w-full last:min-h-[80vh]" + > + <SessionTurn + sessionID={params.id!} + messageID={message.id} + lastUserMessageID={lastUserMessage()?.id} + stepsExpanded={store.mobileStepsExpanded[message.id] ?? false} + onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)} + onUserInteracted={() => setStore("userInteracted", true)} + classes={{ + root: "min-w-0 w-full relative", + content: + "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + container: + "px-4 md:px-6 " + + (!showTabs() + ? "md:max-w-200 md:mx-auto" + : visibleUserMessages().length > 1 + ? "md:pr-6 md:pl-18" + : ""), + }} + /> + </div> + )} + </For> + </div> + </div> + </div> + </Match> + <Match when={true}> + <NewSessionView /> + </Match> + </Switch> + </Show> </div> {/* Prompt input */} @@ -811,9 +855,22 @@ export default function Page() { </div> {/* Mobile review panel - only shown on mobile when review tab is active */} - <Show when={diffs().length > 0 && mobileTab() === "review"}> + <Show when={diffs().length > 0 && store.mobileTab === "review"}> <div class="md:hidden flex-1 min-h-0 mt-6 pb-32"> - <ReviewPanel /> + <div class="relative h-full overflow-y-auto no-scrollbar"> + <SessionReview + diffs={diffs()} + diffStyle={layout.review.diffStyle()} + onDiffStyleChange={layout.review.setDiffStyle} + open={view().review.open()} + onOpenChange={view().review.setOpen} + classes={{ + root: "pb-32", + header: "px-4", + container: "px-4", + }} + /> + </div> </div> </Show> |
