summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-01 19:26:55 -0600
committerAdam <[email protected]>2026-01-01 21:03:06 -0600
commit797d8425e08f5ee53fa396716f9fe9ed82c18156 (patch)
tree22e9a89be67090a8760826900a7c1d1e39f8433a
parent260eef2d6687a1cc030f8ae3dda394c64e460d8e (diff)
downloadopencode-797d8425e08f5ee53fa396716f9fe9ed82c18156.tar.gz
opencode-797d8425e08f5ee53fa396716f9fe9ed82c18156.zip
wip(app): progress
-rw-r--r--packages/app/src/components/session/session-review-tab.tsx11
-rw-r--r--packages/app/src/pages/session.tsx284
2 files changed, 185 insertions, 110 deletions
diff --git a/packages/app/src/components/session/session-review-tab.tsx b/packages/app/src/components/session/session-review-tab.tsx
index 078527eca..c9656eb72 100644
--- a/packages/app/src/components/session/session-review-tab.tsx
+++ b/packages/app/src/components/session/session-review-tab.tsx
@@ -6,6 +6,11 @@ import type { FileDiff } from "@opencode-ai/sdk/v2/client"
interface SessionReviewTabProps {
diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+ classes?: {
+ root?: string
+ header?: string
+ container?: string
+ }
}
export function SessionReviewTab(props: SessionReviewTabProps) {
@@ -69,9 +74,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
open={props.view().review.open()}
onOpenChange={props.view().review.setOpen}
classes={{
- root: "pb-40",
- header: "px-6",
- container: "px-6",
+ root: props.classes?.root ?? "pb-40",
+ header: props.classes?.header ?? "px-6",
+ container: props.classes?.container ?? "px-6",
}}
diffs={props.diffs()}
diffStyle={layout.review.diffStyle()}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 23ded094e..d53b99865 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,5 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
+import { createMediaQuery } from "@solid-primitives/media"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -15,7 +16,7 @@ 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"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
@@ -70,6 +71,8 @@ export default function Page() {
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
+ const isDesktop = createMediaQuery("(min-width: 768px)")
+
function normalizeTab(tab: string) {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
@@ -540,6 +543,7 @@ export default function Page() {
)
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
+ const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
const showTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
@@ -616,39 +620,98 @@ export default function Page() {
}
const messageRefs = new Map<string, HTMLDivElement>()
- let scrollTimer: number
+ let scrollTimer: number | undefined
+
+ createEffect(() => {
+ const msgs = visibleUserMessages()
+ if (msgs.length === 0) {
+ messageRefs.clear()
+ return
+ }
+
+ const ids = new Set(msgs.map((m) => m.id))
+ for (const id of messageRefs.keys()) {
+ if (ids.has(id)) continue
+ messageRefs.delete(id)
+ }
+ })
+
+ let scrollSpyIndex = 0
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setStore("ignoreScrollSpy", true)
setActiveMessage(message)
+
+ const msgs = visibleUserMessages()
+ const idx = msgs.findIndex((m) => m.id === message.id)
+ if (idx >= 0) scrollSpyIndex = idx
+
const el = messageRefs.get(message.id)
if (el) {
el.scrollIntoView({ behavior, block: "start" })
}
- window.clearTimeout(scrollTimer)
+
+ if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
}
- const handleScrollSpy = (e: Event) => {
+ let scrollSpyFrame: number | undefined
+ let scrollSpyTarget: HTMLDivElement | undefined
+
+ const scheduleScrollSpy = (container: HTMLDivElement) => {
if (store.ignoreScrollSpy) return
- const container = e.target as HTMLDivElement
- const scrollTop = container.scrollTop
- const threshold = 100
-
- let activeId: string | undefined
- for (const message of visibleUserMessages()) {
- const el = messageRefs.get(message.id)
- if (!el) continue
- if (el.offsetTop <= scrollTop + threshold) {
- activeId = message.id
- } else {
+ scrollSpyTarget = container
+ if (scrollSpyFrame !== undefined) return
+
+ scrollSpyFrame = requestAnimationFrame(() => {
+ scrollSpyFrame = undefined
+ const target = scrollSpyTarget
+ scrollSpyTarget = undefined
+ if (!target) return
+ if (store.ignoreScrollSpy) return
+
+ const msgs = visibleUserMessages()
+ const scrollTop = target.scrollTop
+ const threshold = 100
+ const cutoff = scrollTop + threshold
+
+ if (msgs.length === 0) return
+
+ if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
+ if (scrollSpyIndex < 0) scrollSpyIndex = 0
+
+ while (scrollSpyIndex + 1 < msgs.length) {
+ const next = msgs[scrollSpyIndex + 1]
+ if (!next) break
+
+ const el = messageRefs.get(next.id)
+ if (!el) break
+ if (el.offsetTop <= cutoff) {
+ scrollSpyIndex += 1
+ continue
+ }
break
}
- }
- if (activeId && activeId !== activeMessage()?.id) {
- setActiveMessage(visibleUserMessages().find((m) => m.id === activeId))
- }
+ while (scrollSpyIndex > 0) {
+ const cur = msgs[scrollSpyIndex]
+ if (!cur) break
+
+ const el = messageRefs.get(cur.id)
+ if (!el) break
+ if (el.offsetTop > cutoff) {
+ scrollSpyIndex -= 1
+ continue
+ }
+ break
+ }
+
+ const msg = msgs[scrollSpyIndex]
+ if (!msg) return
+ if (msg.id === activeMessage()?.id) return
+
+ setActiveMessage(msg)
+ })
}
createEffect(
@@ -656,7 +719,13 @@ export default function Page() {
() => params.id,
(id) => {
cancelInitialScroll()
+ if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
+ scrollTimer = undefined
+ if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
+ scrollSpyFrame = undefined
+ scrollSpyTarget = undefined
messageRefs.clear()
+ scrollSpyIndex = 0
initialScrollTarget = undefined
setStore("initialScrollDone", !id)
},
@@ -697,8 +766,9 @@ export default function Page() {
if (msgs.length === 0) return
requestAnimationFrame(() => {
if (!scrollContainer) return
+ if (!isDesktop()) return
// Manually trigger spy once to set initial active message based on scroll position
- handleScrollSpy({ target: scrollContainer } as unknown as Event)
+ scheduleScrollSpy(scrollContainer)
})
})
@@ -709,6 +779,8 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
cancelInitialScroll()
+ if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
+ if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
return (
@@ -716,8 +788,8 @@ export default function Page() {
<SessionHeader />
<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}>
- <div class="md:hidden flex border-b border-border-weak-base bg-background-base">
+ <Show when={!isDesktop() && diffs().length > 0}>
+ <div class="flex border-b border-border-weak-base bg-background-base">
<button
type="button"
class="flex-1 py-3 text-14-medium border-b-2 transition-colors"
@@ -747,78 +819,95 @@ export default function Page() {
<div
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 && store.mobileTab === "review",
"flex-1 md:flex-none": true,
}}
- style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
+ style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }}
>
<div class="flex-1 min-h-0 overflow-hidden">
<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,
- }}
- >
+ <Show
+ when={!mobileReview()}
+ fallback={
+ <div class="relative h-full mt-6 overflow-hidden">
+ <SessionReviewTab
+ diffs={diffs}
+ view={view}
+ classes={{
+ root: "pb-32",
+ header: "px-4",
+ container: "px-4",
+ }}
+ />
+ </div>
+ }
+ >
+ <div class="relative w-full h-full min-w-0">
+ <Show when={isDesktop()}>
+ <div class="absolute inset-0 pointer-events-none z-10">
+ <SessionMessageRail
+ messages={visibleUserMessages()}
+ current={activeMessage()}
+ onMessageSelect={scrollToMessage}
+ wide={!showTabs()}
+ class="pointer-events-auto"
+ />
+ </div>
+ </Show>
<div
- ref={autoScroll.contentRef}
- class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
+ ref={setScrollRef}
+ onScroll={(e) => {
+ autoScroll.handleScroll()
+ if (isDesktop()) scheduleScrollSpy(e.currentTarget)
+ }}
+ onClick={autoScroll.handleInteraction}
+ class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
classList={{
- "mt-0.5": !showTabs(),
- "mt-0": showTabs(),
+ "opacity-0 pointer-events-none": !store.initialScrollDone,
}}
>
- <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
+ 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>
- </div>
+ </Show>
</Match>
<Match when={true}>
<NewSessionView />
@@ -843,7 +932,7 @@ export default function Page() {
</div>
</div>
- <Show when={showTabs()}>
+ <Show when={isDesktop() && showTabs()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
@@ -854,29 +943,9 @@ export default function Page() {
</Show>
</div>
- {/* Mobile review panel - only shown on mobile when review tab is active */}
- <Show when={diffs().length > 0 && store.mobileTab === "review"}>
- <div class="md:hidden flex-1 min-h-0 mt-6 pb-32">
- <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>
-
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
- <Show when={showTabs()}>
- <div class="hidden md:block relative flex-1 min-w-0 h-full border-l border-border-weak-base">
+ <Show when={isDesktop() && showTabs()}>
+ <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
@@ -972,6 +1041,7 @@ export default function Page() {
return file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => checksum(contents()))
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
@@ -1080,7 +1150,7 @@ export default function Page() {
file={{
name: path() ?? "",
contents: contents(),
- cacheKey: checksum(contents()),
+ cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
@@ -1122,9 +1192,9 @@ export default function Page() {
</Show>
</div>
- <Show when={layout.terminal.opened()}>
+ <Show when={isDesktop() && layout.terminal.opened()}>
<div
- class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
+ class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle