summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 09:37:49 -0600
committerGitHub <[email protected]>2026-02-06 09:37:49 -0600
commita4bc883595df9ea0f752079519081bc602408553 (patch)
tree583f21642f431899abe1dfb1f6bd9b2c01dc0206 /packages/app/src/pages
parentc07077f96c0019b2e18e0e8e1e0383deda08b3e6 (diff)
downloadopencode-a4bc883595df9ea0f752079519081bc602408553.tar.gz
opencode-a4bc883595df9ea0f752079519081bc602408553.zip
chore: refactoring and tests (#12468)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/session.tsx503
-rw-r--r--packages/app/src/pages/session/helpers.test.ts61
-rw-r--r--packages/app/src/pages/session/helpers.ts38
-rw-r--r--packages/app/src/pages/session/scroll-spy.test.ts127
-rw-r--r--packages/app/src/pages/session/scroll-spy.ts274
5 files changed, 713 insertions, 290 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 433e47925..67606e860 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -75,6 +75,8 @@ import {
} from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "@/pages/session/helpers"
+import { createScrollSpy } from "@/pages/session/scroll-spy"
type DiffStyle = "unified" | "split"
@@ -872,19 +874,7 @@ export default function Page() {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
- const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
- const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
- if (!element) return
-
- // Find and focus the ghostty textarea (the actual input element)
- const textarea = element.querySelector("textarea") as HTMLTextAreaElement
- if (textarea) {
- textarea.focus()
- return
- }
- // Fallback: focus container and dispatch pointer event
- element.focus()
- element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+ focusTerminalById(activeId)
},
),
)
@@ -973,7 +963,7 @@ export default function Page() {
})
}
- command.register(() => [
+ const sessionCommands = createMemo(() => [
{
id: "session.new",
title: language.t("command.session.new"),
@@ -982,6 +972,9 @@ export default function Page() {
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
+ ])
+
+ const fileCommands = createMemo(() => [
{
id: "file.open",
title: language.t("command.file.open"),
@@ -989,7 +982,7 @@ export default function Page() {
category: language.t("command.category.file"),
keybind: "mod+p",
slash: "open",
- onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
+ onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
},
{
id: "tab.close",
@@ -1003,6 +996,9 @@ export default function Page() {
tabs().close(active)
},
},
+ ])
+
+ const contextCommands = createMemo(() => [
{
id: "context.addSelection",
title: language.t("command.context.addSelection"),
@@ -1034,6 +1030,9 @@ export default function Page() {
addSelectionToContext(path, selectionFromLines(range))
},
},
+ ])
+
+ const viewCommands = createMemo(() => [
{
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
@@ -1087,6 +1086,9 @@ export default function Page() {
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
},
+ ])
+
+ const messageCommands = createMemo(() => [
{
id: "message.previous",
title: language.t("command.message.previous"),
@@ -1105,6 +1107,9 @@ export default function Page() {
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
+ ])
+
+ const agentCommands = createMemo(() => [
{
id: "model.choose",
title: language.t("command.model.choose"),
@@ -1150,6 +1155,9 @@ export default function Page() {
local.model.variant.cycle()
},
},
+ ])
+
+ const permissionCommands = createMemo(() => [
{
id: "permissions.autoaccept",
title:
@@ -1173,6 +1181,9 @@ export default function Page() {
})
},
},
+ ])
+
+ const sessionActionCommands = createMemo(() => [
{
id: "session.undo",
title: language.t("command.session.undo"),
@@ -1187,17 +1198,14 @@ export default function Page() {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
- // Find the last user message that's not already reverted
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
- // Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
- // Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
@@ -1216,17 +1224,13 @@ export default function Page() {
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
- // Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
- // Navigate to the last message (the one that was at the revert point)
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
- // Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
- // Navigate to the message before the new revert point
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
@@ -1265,74 +1269,90 @@ export default function Page() {
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
- ...(sync.data.config.share !== "disabled"
- ? [
- {
- id: "session.share",
- title: language.t("command.session.share"),
- description: language.t("command.session.share.description"),
- category: language.t("command.category.session"),
- slash: "share",
- disabled: !params.id || !!info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .share({ sessionID: params.id })
- .then((res) => {
- navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
- showToast({
- title: language.t("toast.session.share.copyFailed.title"),
- variant: "error",
- }),
- )
- })
- .then(() =>
- showToast({
- title: language.t("toast.session.share.success.title"),
- description: language.t("toast.session.share.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.share.failed.title"),
- description: language.t("toast.session.share.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- {
- id: "session.unshare",
- title: language.t("command.session.unshare"),
- description: language.t("command.session.unshare.description"),
- category: language.t("command.category.session"),
- slash: "unshare",
- disabled: !params.id || !info()?.share?.url,
- onSelect: async () => {
- if (!params.id) return
- await sdk.client.session
- .unshare({ sessionID: params.id })
- .then(() =>
- showToast({
- title: language.t("toast.session.unshare.success.title"),
- description: language.t("toast.session.unshare.success.description"),
- variant: "success",
- }),
- )
- .catch(() =>
- showToast({
- title: language.t("toast.session.unshare.failed.title"),
- description: language.t("toast.session.unshare.failed.description"),
- variant: "error",
- }),
- )
- },
- },
- ]
- : []),
])
+ const shareCommands = createMemo(() => {
+ if (sync.data.config.share === "disabled") return []
+ return [
+ {
+ id: "session.share",
+ title: language.t("command.session.share"),
+ description: language.t("command.session.share.description"),
+ category: language.t("command.category.session"),
+ slash: "share",
+ disabled: !params.id || !!info()?.share?.url,
+ onSelect: async () => {
+ if (!params.id) return
+ await sdk.client.session
+ .share({ sessionID: params.id })
+ .then((res) => {
+ navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+ showToast({
+ title: language.t("toast.session.share.copyFailed.title"),
+ variant: "error",
+ }),
+ )
+ })
+ .then(() =>
+ showToast({
+ title: language.t("toast.session.share.success.title"),
+ description: language.t("toast.session.share.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: language.t("toast.session.share.failed.title"),
+ description: language.t("toast.session.share.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ {
+ id: "session.unshare",
+ title: language.t("command.session.unshare"),
+ description: language.t("command.session.unshare.description"),
+ category: language.t("command.category.session"),
+ slash: "unshare",
+ disabled: !params.id || !info()?.share?.url,
+ onSelect: async () => {
+ if (!params.id) return
+ await sdk.client.session
+ .unshare({ sessionID: params.id })
+ .then(() =>
+ showToast({
+ title: language.t("toast.session.unshare.success.title"),
+ description: language.t("toast.session.unshare.success.description"),
+ variant: "success",
+ }),
+ )
+ .catch(() =>
+ showToast({
+ title: language.t("toast.session.unshare.failed.title"),
+ description: language.t("toast.session.unshare.failed.description"),
+ variant: "error",
+ }),
+ )
+ },
+ },
+ ]
+ })
+
+ command.register("session", () =>
+ combineCommandSections([
+ sessionCommands(),
+ fileCommands(),
+ contextCommands(),
+ viewCommands(),
+ messageCommands(),
+ agentCommands(),
+ permissionCommands(),
+ sessionActionCommands(),
+ shareCommands(),
+ ]),
+ )
+
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
@@ -1407,19 +1427,7 @@ export default function Page() {
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
- const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
- const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
- if (!element) return
-
- // Find and focus the ghostty textarea (the actual input element)
- const textarea = element.querySelector("textarea") as HTMLTextAreaElement
- if (textarea) {
- textarea.focus()
- return
- }
- // Fallback: focus container and dispatch pointer event
- element.focus()
- element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
+ focusTerminalById(activeId)
}, 0)
}
@@ -1457,6 +1465,13 @@ export default function Page() {
setFileTreeTab("all")
}
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles,
+ tabForPath: file.tab,
+ openTab: tabs().open,
+ loadFile: file.load,
+ })
+
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
@@ -1481,65 +1496,72 @@ export default function Page() {
</div>
)
+ const reviewContent = (input: {
+ diffStyle: DiffStyle
+ onDiffStyleChange?: (style: DiffStyle) => void
+ classes?: SessionReviewTabProps["classes"]
+ loadingClass: string
+ emptyClass: string
+ }) => (
+ <Switch>
+ <Match when={store.changes === "turn" && !!params.id}>
+ <SessionReviewTab
+ title={changesTitle()}
+ empty={emptyTurn()}
+ diffs={reviewDiffs}
+ view={view}
+ diffStyle={input.diffStyle}
+ onDiffStyleChange={input.onDiffStyleChange}
+ onScrollRef={(el) => setTree("reviewScroll", el)}
+ focusedFile={tree.activeDiff}
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
+ onViewFile={openReviewFile}
+ classes={input.classes}
+ />
+ </Match>
+ <Match when={hasReview()}>
+ <Show
+ when={diffsReady()}
+ fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
+ >
+ <SessionReviewTab
+ title={changesTitle()}
+ diffs={reviewDiffs}
+ view={view}
+ diffStyle={input.diffStyle}
+ onDiffStyleChange={input.onDiffStyleChange}
+ onScrollRef={(el) => setTree("reviewScroll", el)}
+ focusedFile={tree.activeDiff}
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+ comments={comments.all()}
+ focusedComment={comments.focus()}
+ onFocusedCommentChange={comments.setFocus}
+ onViewFile={openReviewFile}
+ classes={input.classes}
+ />
+ </Show>
+ </Match>
+ <Match when={true}>
+ <div class={input.emptyClass}>
+ <Mark class="w-14 opacity-10" />
+ <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
+ </div>
+ </Match>
+ </Switch>
+ )
+
const reviewPanel = () => (
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
- <Switch>
- <Match when={store.changes === "turn" && !!params.id}>
- <SessionReviewTab
- title={changesTitle()}
- empty={emptyTurn()}
- diffs={reviewDiffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onScrollRef={(el) => setTree("reviewScroll", el)}
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
- </Match>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
- >
- <SessionReviewTab
- title={changesTitle()}
- diffs={reviewDiffs}
- view={view}
- diffStyle={layout.review.diffStyle()}
- onDiffStyleChange={layout.review.setDiffStyle}
- onScrollRef={(el) => setTree("reviewScroll", el)}
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
- </div>
- </Match>
- </Switch>
+ {reviewContent({
+ diffStyle: layout.review.diffStyle(),
+ onDiffStyleChange: layout.review.setDiffStyle,
+ loadingClass: "px-6 py-4 text-text-weak",
+ emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
</div>
</div>
)
@@ -1656,6 +1678,12 @@ export default function Page() {
return "empty"
})
+ const activeFileTab = createMemo(() => {
+ const active = activeTab()
+ if (!openedTabs().includes(active)) return
+ return active
+ })
+
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
@@ -1760,6 +1788,12 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
+ const scrollSpy = createScrollSpy({
+ onActive: (id) => {
+ if (id === store.messageId) return
+ setStore("messageId", id)
+ },
+ })
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
@@ -1807,16 +1841,11 @@ export default function Page() {
),
)
- let scrollSpyFrame: number | undefined
- let scrollSpyTarget: HTMLDivElement | undefined
-
createEffect(
on(
sessionKey,
() => {
- if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
- scrollSpyFrame = undefined
- scrollSpyTarget = undefined
+ scrollSpy.clear()
},
{ defer: true },
),
@@ -1827,6 +1856,7 @@ export default function Page() {
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
+ scrollSpy.setContainer(el)
if (el) scheduleScrollState(el)
}
@@ -1835,6 +1865,7 @@ export default function Page() {
() => {
const el = scroller
if (el) scheduleScrollState(el)
+ scrollSpy.markDirty()
},
)
@@ -1940,6 +1971,7 @@ export default function Page() {
}
if (el) scheduleScrollState(el)
+ scrollSpy.markDirty()
},
)
@@ -2053,61 +2085,6 @@ export default function Page() {
if (el) scheduleScrollState(el)
}
- const closestMessage = (node: Element | null): HTMLElement | null => {
- if (!node) return null
- const match = node.closest?.("[data-message-id]") as HTMLElement | null
- if (match) return match
- const root = node.getRootNode?.()
- if (root instanceof ShadowRoot) return closestMessage(root.host)
- return null
- }
-
- const getActiveMessageId = (container: HTMLDivElement) => {
- const rect = container.getBoundingClientRect()
- if (!rect.width || !rect.height) return
-
- const x = Math.min(window.innerWidth - 1, Math.max(0, rect.left + rect.width / 2))
- const y = Math.min(window.innerHeight - 1, Math.max(0, rect.top + 100))
-
- const hit = document.elementFromPoint(x, y)
- const host = closestMessage(hit)
- const id = host?.dataset.messageId
- if (id) return id
-
- // Fallback: DOM query (handles edge hit-testing cases)
- const cutoff = container.scrollTop + 100
- const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
- let last: string | undefined
-
- for (const node of nodes) {
- const next = node.dataset.messageId
- if (!next) continue
- if (node.offsetTop > cutoff) break
- last = next
- }
-
- return last
- }
-
- const scheduleScrollSpy = (container: HTMLDivElement) => {
- scrollSpyTarget = container
- if (scrollSpyFrame !== undefined) return
-
- scrollSpyFrame = requestAnimationFrame(() => {
- scrollSpyFrame = undefined
-
- const target = scrollSpyTarget
- scrollSpyTarget = undefined
- if (!target) return
-
- const id = getActiveMessageId(target)
- if (!id) return
- if (id === store.messageId) return
-
- setStore("messageId", id)
- })
- }
-
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
@@ -2215,7 +2192,7 @@ export default function Page() {
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
- if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
+ scrollSpy.destroy()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
})
@@ -2272,74 +2249,16 @@ export default function Page() {
when={!mobileChanges()}
fallback={
<div class="relative h-full overflow-hidden">
- <Switch>
- <Match when={store.changes === "turn" && !!params.id}>
- <SessionReviewTab
- title={changesTitle()}
- empty={emptyTurn()}
- diffs={reviewDiffs}
- view={view}
- diffStyle="unified"
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- classes={{
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- }}
- />
- </Match>
- <Match when={hasReview()}>
- <Show
- when={diffsReady()}
- fallback={
- <div class="px-4 py-4 text-text-weak">
- {language.t("session.review.loadingChanges")}
- </div>
- }
- >
- <SessionReviewTab
- title={changesTitle()}
- diffs={reviewDiffs}
- view={view}
- diffStyle="unified"
- focusedFile={tree.activeDiff}
- onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
- comments={comments.all()}
- focusedComment={comments.focus()}
- onFocusedCommentChange={comments.setFocus}
- onViewFile={(path) => {
- showAllFiles()
- const value = file.tab(path)
- tabs().open(value)
- file.load(path)
- }}
- classes={{
- root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
- header: "px-4",
- container: "px-4",
- }}
- />
- </Show>
- </Match>
- <Match when={true}>
- <div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
- <Mark class="w-14 opacity-10" />
- <div class="text-14-regular text-text-weak max-w-56">
- {language.t("session.review.empty")}
- </div>
- </div>
- </Match>
- </Switch>
+ {reviewContent({
+ diffStyle: "unified",
+ classes: {
+ root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
+ header: "px-4",
+ container: "px-4",
+ },
+ loadingClass: "px-4 py-4 text-text-weak",
+ emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
+ })}
</div>
}
>
@@ -2451,7 +2370,7 @@ export default function Page() {
if (!hasScrollGesture()) return
autoScroll.handleScroll()
markScrollGesture(e.currentTarget)
- if (isDesktop()) scheduleScrollSpy(e.currentTarget)
+ if (isDesktop()) scrollSpy.onScroll()
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
@@ -2636,6 +2555,10 @@ export default function Page() {
<div
id={anchor(message.id)}
data-message-id={message.id}
+ ref={(el) => {
+ scrollSpy.register(el, message.id)
+ onCleanup(() => scrollSpy.unregister(message.id))
+ }}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 3xl:max-w-[1200px]": centered(),
@@ -2979,7 +2902,7 @@ export default function Page() {
</Tabs.Content>
</Show>
- <For each={openedTabs()}>
+ <Show when={activeFileTab()} keyed>
{(tab) => {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
@@ -3483,7 +3406,7 @@ export default function Page() {
</Tabs.Content>
)
}}
- </For>
+ </Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts
new file mode 100644
index 000000000..0afc7eb6a
--- /dev/null
+++ b/packages/app/src/pages/session/helpers.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test"
+import { combineCommandSections, createOpenReviewFile, focusTerminalById } from "./helpers"
+
+describe("createOpenReviewFile", () => {
+ test("opens and loads selected review file", () => {
+ const calls: string[] = []
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles: () => calls.push("show"),
+ tabForPath: (path) => {
+ calls.push(`tab:${path}`)
+ return `file://${path}`
+ },
+ openTab: (tab) => calls.push(`open:${tab}`),
+ loadFile: (path) => calls.push(`load:${path}`),
+ })
+
+ openReviewFile("src/a.ts")
+
+ expect(calls).toEqual(["show", "tab:src/a.ts", "open:file://src/a.ts", "load:src/a.ts"])
+ })
+})
+
+describe("focusTerminalById", () => {
+ test("focuses textarea when present", () => {
+ document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`
+
+ const focused = focusTerminalById("one")
+
+ expect(focused).toBe(true)
+ expect(document.activeElement?.tagName).toBe("TEXTAREA")
+ })
+
+ test("falls back to terminal element focus", () => {
+ document.body.innerHTML = `<div id="terminal-wrapper-two"><div data-component="terminal" tabindex="0"></div></div>`
+ const terminal = document.querySelector('[data-component="terminal"]') as HTMLElement
+ let pointerDown = false
+ terminal.addEventListener("pointerdown", () => {
+ pointerDown = true
+ })
+
+ const focused = focusTerminalById("two")
+
+ expect(focused).toBe(true)
+ expect(document.activeElement).toBe(terminal)
+ expect(pointerDown).toBe(true)
+ })
+})
+
+describe("combineCommandSections", () => {
+ test("keeps section order stable", () => {
+ const result = combineCommandSections([
+ [{ id: "a", title: "A" }],
+ [
+ { id: "b", title: "B" },
+ { id: "c", title: "C" },
+ ],
+ ])
+
+ expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
+ })
+})
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts
new file mode 100644
index 000000000..d9ce90793
--- /dev/null
+++ b/packages/app/src/pages/session/helpers.ts
@@ -0,0 +1,38 @@
+import type { CommandOption } from "@/context/command"
+
+export const focusTerminalById = (id: string) => {
+ const wrapper = document.getElementById(`terminal-wrapper-${id}`)
+ const terminal = wrapper?.querySelector('[data-component="terminal"]')
+ if (!(terminal instanceof HTMLElement)) return false
+
+ const textarea = terminal.querySelector("textarea")
+ if (textarea instanceof HTMLTextAreaElement) {
+ textarea.focus()
+ return true
+ }
+
+ terminal.focus()
+ terminal.dispatchEvent(
+ typeof PointerEvent === "function"
+ ? new PointerEvent("pointerdown", { bubbles: true, cancelable: true })
+ : new MouseEvent("pointerdown", { bubbles: true, cancelable: true }),
+ )
+ return true
+}
+
+export const createOpenReviewFile = (input: {
+ showAllFiles: () => void
+ tabForPath: (path: string) => string
+ openTab: (tab: string) => void
+ loadFile: (path: string) => void
+}) => {
+ return (path: string) => {
+ input.showAllFiles()
+ input.openTab(input.tabForPath(path))
+ input.loadFile(path)
+ }
+}
+
+export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
+ return sections.flatMap((section) => section)
+}
diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts
new file mode 100644
index 000000000..f3e6775cb
--- /dev/null
+++ b/packages/app/src/pages/session/scroll-spy.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, test } from "bun:test"
+import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy"
+
+const rect = (top: number, height = 80): DOMRect =>
+ ({
+ x: 0,
+ y: top,
+ top,
+ left: 0,
+ right: 800,
+ bottom: top + height,
+ width: 800,
+ height,
+ toJSON: () => ({}),
+ }) as DOMRect
+
+const setRect = (el: Element, top: number, height = 80) => {
+ Object.defineProperty(el, "getBoundingClientRect", {
+ configurable: true,
+ value: () => rect(top, height),
+ })
+}
+
+describe("pickVisibleId", () => {
+ test("prefers higher intersection ratio", () => {
+ const id = pickVisibleId(
+ [
+ { id: "a", ratio: 0.2, top: 100 },
+ { id: "b", ratio: 0.8, top: 300 },
+ ],
+ 120,
+ )
+
+ expect(id).toBe("b")
+ })
+
+ test("breaks ratio ties by nearest line", () => {
+ const id = pickVisibleId(
+ [
+ { id: "a", ratio: 0.5, top: 90 },
+ { id: "b", ratio: 0.5, top: 140 },
+ ],
+ 130,
+ )
+
+ expect(id).toBe("b")
+ })
+})
+
+describe("pickOffsetId", () => {
+ test("uses binary search cutoff", () => {
+ const id = pickOffsetId(
+ [
+ { id: "a", top: 0 },
+ { id: "b", top: 200 },
+ { id: "c", top: 400 },
+ ],
+ 350,
+ )
+
+ expect(id).toBe("b")
+ })
+})
+
+describe("createScrollSpy fallback", () => {
+ test("tracks active id from offsets and dirty refresh", () => {
+ const active: string[] = []
+ const root = document.createElement("div") as HTMLDivElement
+ const one = document.createElement("div")
+ const two = document.createElement("div")
+ const three = document.createElement("div")
+
+ root.append(one, two, three)
+ document.body.append(root)
+
+ Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 })
+ setRect(root, 0, 800)
+ setRect(one, -250)
+ setRect(two, -50)
+ setRect(three, 150)
+
+ const queue: FrameRequestCallback[] = []
+ const flush = () => {
+ const run = [...queue]
+ queue.length = 0
+ for (const cb of run) cb(0)
+ }
+
+ const spy = createScrollSpy({
+ onActive: (id) => active.push(id),
+ raf: (cb) => (queue.push(cb), queue.length),
+ caf: () => {},
+ IntersectionObserver: undefined,
+ ResizeObserver: undefined,
+ MutationObserver: undefined,
+ })
+
+ spy.setContainer(root)
+ spy.register(one, "a")
+ spy.register(two, "b")
+ spy.register(three, "c")
+ spy.onScroll()
+ flush()
+
+ expect(spy.getActiveId()).toBe("b")
+ expect(active.at(-1)).toBe("b")
+
+ root.scrollTop = 450
+ setRect(one, -450)
+ setRect(two, -250)
+ setRect(three, -50)
+ spy.onScroll()
+ flush()
+ expect(spy.getActiveId()).toBe("c")
+
+ root.scrollTop = 250
+ setRect(one, -250)
+ setRect(two, 250)
+ setRect(three, 150)
+ spy.markDirty()
+ spy.onScroll()
+ flush()
+ expect(spy.getActiveId()).toBe("a")
+
+ spy.destroy()
+ })
+})
diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts
new file mode 100644
index 000000000..8c52d77dc
--- /dev/null
+++ b/packages/app/src/pages/session/scroll-spy.ts
@@ -0,0 +1,274 @@
+type Visible = {
+ id: string
+ ratio: number
+ top: number
+}
+
+type Offset = {
+ id: string
+ top: number
+}
+
+type Input = {
+ onActive: (id: string) => void
+ raf?: (cb: FrameRequestCallback) => number
+ caf?: (id: number) => void
+ IntersectionObserver?: typeof globalThis.IntersectionObserver
+ ResizeObserver?: typeof globalThis.ResizeObserver
+ MutationObserver?: typeof globalThis.MutationObserver
+}
+
+export const pickVisibleId = (list: Visible[], line: number) => {
+ if (list.length === 0) return
+
+ const sorted = [...list].sort((a, b) => {
+ if (b.ratio !== a.ratio) return b.ratio - a.ratio
+
+ const da = Math.abs(a.top - line)
+ const db = Math.abs(b.top - line)
+ if (da !== db) return da - db
+
+ return a.top - b.top
+ })
+
+ return sorted[0]?.id
+}
+
+export const pickOffsetId = (list: Offset[], cutoff: number) => {
+ if (list.length === 0) return
+
+ let lo = 0
+ let hi = list.length - 1
+ let out = 0
+
+ while (lo <= hi) {
+ const mid = (lo + hi) >> 1
+ const top = list[mid]?.top
+ if (top === undefined) break
+
+ if (top <= cutoff) {
+ out = mid
+ lo = mid + 1
+ continue
+ }
+
+ hi = mid - 1
+ }
+
+ return list[out]?.id
+}
+
+export const createScrollSpy = (input: Input) => {
+ const raf = input.raf ?? requestAnimationFrame
+ const caf = input.caf ?? cancelAnimationFrame
+ const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver
+ const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver
+ const CtorMO = input.MutationObserver ?? globalThis.MutationObserver
+
+ let root: HTMLDivElement | undefined
+ let io: IntersectionObserver | undefined
+ let ro: ResizeObserver | undefined
+ let mo: MutationObserver | undefined
+ let frame: number | undefined
+ let active: string | undefined
+ let dirty = true
+
+ const node = new Map<string, HTMLElement>()
+ const id = new WeakMap<HTMLElement, string>()
+ const visible = new Map<string, { ratio: number; top: number }>()
+ let offset: Offset[] = []
+
+ const schedule = () => {
+ if (frame !== undefined) return
+ frame = raf(() => {
+ frame = undefined
+ update()
+ })
+ }
+
+ const refreshOffset = () => {
+ const el = root
+ if (!el) {
+ offset = []
+ dirty = false
+ return
+ }
+
+ const base = el.getBoundingClientRect().top
+ offset = [...node].map(([next, item]) => ({
+ id: next,
+ top: item.getBoundingClientRect().top - base + el.scrollTop,
+ }))
+ offset.sort((a, b) => a.top - b.top)
+ dirty = false
+ }
+
+ const update = () => {
+ const el = root
+ if (!el) return
+
+ const line = el.getBoundingClientRect().top + 100
+ const next =
+ pickVisibleId(
+ [...visible].map(([k, v]) => ({
+ id: k,
+ ratio: v.ratio,
+ top: v.top,
+ })),
+ line,
+ ) ??
+ (() => {
+ if (dirty) refreshOffset()
+ return pickOffsetId(offset, el.scrollTop + 100)
+ })()
+
+ if (!next || next === active) return
+ active = next
+ input.onActive(next)
+ }
+
+ const observe = () => {
+ const el = root
+ if (!el) return
+
+ io?.disconnect()
+ io = undefined
+ if (CtorIO) {
+ try {
+ io = new CtorIO(
+ (entries) => {
+ for (const entry of entries) {
+ const item = entry.target
+ if (!(item instanceof HTMLElement)) continue
+ const key = id.get(item)
+ if (!key) continue
+
+ if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
+ visible.delete(key)
+ continue
+ }
+
+ visible.set(key, {
+ ratio: entry.intersectionRatio,
+ top: entry.boundingClientRect.top,
+ })
+ }
+
+ schedule()
+ },
+ {
+ root: el,
+ threshold: [0, 0.25, 0.5, 0.75, 1],
+ },
+ )
+ } catch {
+ io = undefined
+ }
+ }
+
+ if (io) {
+ for (const item of node.values()) io.observe(item)
+ }
+
+ ro?.disconnect()
+ ro = undefined
+ if (CtorRO) {
+ ro = new CtorRO(() => {
+ dirty = true
+ schedule()
+ })
+ ro.observe(el)
+ for (const item of node.values()) ro.observe(item)
+ }
+
+ mo?.disconnect()
+ mo = undefined
+ if (CtorMO) {
+ mo = new CtorMO(() => {
+ dirty = true
+ schedule()
+ })
+ mo.observe(el, { subtree: true, childList: true, characterData: true })
+ }
+
+ dirty = true
+ schedule()
+ }
+
+ const setContainer = (el?: HTMLDivElement) => {
+ if (root === el) return
+
+ root = el
+ visible.clear()
+ active = undefined
+ observe()
+ }
+
+ const register = (el: HTMLElement, key: string) => {
+ const prev = node.get(key)
+ if (prev && prev !== el) {
+ io?.unobserve(prev)
+ ro?.unobserve(prev)
+ }
+
+ node.set(key, el)
+ id.set(el, key)
+ if (io) io.observe(el)
+ if (ro) ro.observe(el)
+ dirty = true
+ schedule()
+ }
+
+ const unregister = (key: string) => {
+ const item = node.get(key)
+ if (!item) return
+
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ node.delete(key)
+ visible.delete(key)
+ dirty = true
+ }
+
+ const markDirty = () => {
+ dirty = true
+ schedule()
+ }
+
+ const clear = () => {
+ for (const item of node.values()) {
+ io?.unobserve(item)
+ ro?.unobserve(item)
+ }
+
+ node.clear()
+ visible.clear()
+ offset = []
+ active = undefined
+ dirty = true
+ }
+
+ const destroy = () => {
+ if (frame !== undefined) caf(frame)
+ frame = undefined
+ clear()
+ io?.disconnect()
+ ro?.disconnect()
+ mo?.disconnect()
+ io = undefined
+ ro = undefined
+ mo = undefined
+ root = undefined
+ }
+
+ return {
+ setContainer,
+ register,
+ unregister,
+ onScroll: schedule,
+ markDirty,
+ clear,
+ destroy,
+ getActiveId: () => active,
+ }
+}