summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/session
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/pages/session
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/pages/session')
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.test.ts40
-rw-r--r--packages/app/src/pages/session/file-tab-scroll.ts67
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx516
-rw-r--r--packages/app/src/pages/session/message-gesture.test.ts62
-rw-r--r--packages/app/src/pages/session/message-gesture.ts21
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx348
-rw-r--r--packages/app/src/pages/session/review-tab.tsx158
-rw-r--r--packages/app/src/pages/session/session-command-helpers.ts10
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx36
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.test.ts22
-rw-r--r--packages/app/src/pages/session/session-prompt-dock.tsx137
-rw-r--r--packages/app/src/pages/session/session-prompt-helpers.ts4
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx306
-rw-r--r--packages/app/src/pages/session/terminal-label.ts16
-rw-r--r--packages/app/src/pages/session/terminal-panel.test.ts25
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx169
-rw-r--r--packages/app/src/pages/session/use-session-commands.test.ts44
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx439
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.test.ts16
-rw-r--r--packages/app/src/pages/session/use-session-hash-scroll.ts174
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,
+ }
+}