import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" import { batch, onCleanup, Show, Match, Switch, createMemo, createEffect, createComputed, on, onMount, untrack, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLocal } from "@/context/local" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" import { Tabs } from "@opencode-ai/ui/tabs" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" import { checksum } from "@opencode-ai/shared/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById, shouldFocusTerminalOnKeyDown, } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" type SessionHistoryWindowInput = { sessionID: () => string | undefined messagesReady: () => boolean loaded: () => number visibleUserMessages: () => UserMessage[] historyMore: () => boolean historyLoading: () => boolean loadMore: (sessionID: string) => Promise userScrolled: () => boolean scroller: () => HTMLDivElement | undefined } /** * Maintains the rendered history window for a session timeline. * * It keeps initial paint bounded to recent turns, reveals cached turns in * small batches while scrolling upward, and prefetches older history near top. */ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { const turnInit = 10 const turnBatch = 8 const turnScrollThreshold = 200 const turnPrefetchBuffer = 16 const prefetchCooldownMs = 400 const prefetchNoGrowthLimit = 2 const [state, setState] = createStore({ turnID: undefined as string | undefined, turnStart: 0, prefetchUntil: 0, prefetchNoGrowth: 0, }) const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0) const turnStart = createMemo(() => { const id = input.sessionID() const len = input.visibleUserMessages().length if (!id || len <= 0) return 0 if (state.turnID !== id) return initialTurnStart(len) if (state.turnStart <= 0) return 0 if (state.turnStart >= len) return initialTurnStart(len) return state.turnStart }) const setTurnStart = (start: number) => { const id = input.sessionID() const next = start > 0 ? start : 0 if (!id) { setState({ turnID: undefined, turnStart: next }) return } setState({ turnID: id, turnStart: next }) } const renderedUserMessages = createMemo( () => { const msgs = input.visibleUserMessages() const start = turnStart() if (start <= 0) return msgs return msgs.slice(start) }, emptyUserMessages, { equals: same, }, ) const preserveScroll = (fn: () => void) => { const el = input.scroller() if (!el) { fn() return } const beforeTop = el.scrollTop const beforeHeight = el.scrollHeight fn() requestAnimationFrame(() => { const delta = el.scrollHeight - beforeHeight if (!delta) return el.scrollTop = beforeTop + delta }) } const backfillTurns = () => { const start = turnStart() if (start <= 0) return const next = start - turnBatch const nextStart = next > 0 ? next : 0 preserveScroll(() => setTurnStart(nextStart)) } /** Button path: reveal all cached turns, fetch older history, reveal one batch. */ const loadAndReveal = async () => { const id = input.sessionID() if (!id) return const start = turnStart() const beforeVisible = input.visibleUserMessages().length let loaded = input.loaded() if (start > 0) setTurnStart(0) if (!input.historyMore() || input.historyLoading()) return let afterVisible = beforeVisible let added = 0 while (true) { await input.loadMore(id) if (input.sessionID() !== id) return afterVisible = input.visibleUserMessages().length const nextLoaded = input.loaded() const raw = nextLoaded - loaded added += raw loaded = nextLoaded if (afterVisible > beforeVisible) break if (raw <= 0) break if (!input.historyMore()) break } if (added <= 0) return if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0) const growth = afterVisible - beforeVisible if (growth <= 0) return if (turnStart() !== 0) return const target = Math.min(afterVisible, beforeVisible + turnBatch) setTurnStart(Math.max(0, afterVisible - target)) } /** Scroll/prefetch path: fetch older history from server. */ const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => { const id = input.sessionID() if (!id) return if (!input.historyMore() || input.historyLoading()) return if (opts?.prefetch) { const now = Date.now() if (state.prefetchUntil > now) return if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return setState("prefetchUntil", now + prefetchCooldownMs) } const start = turnStart() const beforeVisible = input.visibleUserMessages().length const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length let loaded = input.loaded() let added = 0 let growth = 0 while (true) { await input.loadMore(id) if (input.sessionID() !== id) return const nextLoaded = input.loaded() const raw = nextLoaded - loaded added += raw loaded = nextLoaded growth = input.visibleUserMessages().length - beforeVisible if (growth > 0) break if (raw <= 0) break if (opts?.prefetch) break if (!input.historyMore()) break } const afterVisible = input.visibleUserMessages().length if (opts?.prefetch) { setState("prefetchNoGrowth", added > 0 ? 0 : state.prefetchNoGrowth + 1) } else if (added > 0 && state.prefetchNoGrowth) { setState("prefetchNoGrowth", 0) } if (added <= 0) return if (growth <= 0) return if (opts?.prefetch) { const current = turnStart() preserveScroll(() => setTurnStart(current + growth)) return } if (turnStart() !== start) return const currentRendered = renderedUserMessages().length const base = Math.max(beforeRendered, currentRendered) const target = Math.min(afterVisible, base + turnBatch) preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target))) } const onScrollerScroll = () => { if (!input.userScrolled()) return const el = input.scroller() if (!el) return if (el.scrollTop >= turnScrollThreshold) return const start = turnStart() if (start > 0) { if (start <= turnPrefetchBuffer) { void fetchOlderMessages({ prefetch: true }) } backfillTurns() return } void fetchOlderMessages() } createEffect( on( input.sessionID, () => { setState({ prefetchUntil: 0, prefetchNoGrowth: 0 }) }, { defer: true }, ), ) createEffect( on( () => [input.sessionID(), input.messagesReady()] as const, ([id, ready]) => { if (!id || !ready) return setTurnStart(initialTurnStart(input.visibleUserMessages().length)) }, { defer: true }, ), ) return { turnStart, setTurnStart, renderedUserMessages, loadAndReveal, onScrollerScroll, } } export default function Page() { const globalSync = useGlobalSync() const layout = useLayout() const local = useLocal() const file = useFile() const sync = useSync() const dialog = useDialog() const language = useLanguage() const sdk = useSDK() const settings = useSettings() const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const { params, sessionKey, tabs, view } = useSessionLayout() createEffect(() => { if (!prompt.ready()) return untrack(() => { if (params.id) return const text = searchParams.prompt if (!text) return prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) setSearchParams({ ...searchParams, prompt: undefined }) }) }) const [ui, setUi] = createStore({ pendingMessage: undefined as string | undefined, reviewSnap: false, scrollGesture: 0, scroll: { overflow: false, bottom: true, jump: false, }, }) const composer = createSessionComposerState() const workspaceKey = createMemo(() => params.dir ?? "") const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) createEffect( on( () => params.id, (id, prev) => { if (!id) return if (prev) return const pending = layout.handoff.tabs() if (!pending) return if (Date.now() - pending.at > 60_000) { layout.handoff.clearTabs() return } if (pending.id !== id) return layout.handoff.clearTabs() if (pending.dir !== (params.dir ?? "")) return const from = workspaceTabs().tabs() if (from.all.length === 0 && !from.active) return const current = tabs().tabs() if (current.all.length > 0 || current.active) return const all = normalizeTabs(from.all) const active = from.active ? normalizeTab(from.active) : undefined tabs().setAll(all) tabs().setActive(active && all.includes(active) ? active : all[0]) workspaceTabs().setAll([]) workspaceTabs().setActive(undefined) }, { defer: true }, ), ) const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" if (desktopReviewOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab return file.tab(tab) } function normalizeTabs(list: string[]) { const seen = new Set() const next: string[] = [] for (const item of list) { const value = normalizeTab(item) if (seen.has(value)) continue seen.add(value) next.push(value) } return next } const openReviewPanel = () => { if (!view().reviewPanel.opened()) view().reviewPanel.open() } const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, hasReview: canReview, }) const contextOpen = tabState.contextOpen const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { const id = params.id if (!id) return true return sync.data.message[id] !== undefined }) const historyMore = createMemo(() => { const id = params.id if (!id) return false return sync.session.history.more(id) }) const historyLoading = createMemo(() => { const id = params.id if (!id) return false return sync.session.history.loading(id) }) const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages, { equals: same }, ) const visibleUserMessages = createMemo( () => { const revert = revertMessageID() if (!revert) return userMessages() return userMessages().filter((m) => m.id < revert) }, emptyUserMessages, { equals: same, }, ) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) createEffect(() => { const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) if (path) file.load(path) }) createEffect( on( () => lastUserMessage()?.id, () => { const msg = lastUserMessage() if (!msg) return syncSessionModel(local, msg) }, ), ) createEffect( on( () => ({ dir: params.dir, id: params.id }), (next, prev) => { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return if (prev.id && !next.id) local.session.reset() }, { defer: true }, ), ) const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", changes: "git" as ChangeMode, newSessionWorktree: "main", deferRender: false, }) const [vcs, setVcs] = createStore<{ diff: { git: VcsFileDiff[] branch: VcsFileDiff[] } ready: { git: boolean branch: boolean } }>({ diff: { git: [] as VcsFileDiff[], branch: [] as VcsFileDiff[], }, ready: { git: false, branch: false, }, }) const [followup, setFollowup] = persisted( Persist.workspace(sdk.directory, "followup", ["followup.v1"]), createStore<{ items: Record failed: Record paused: Record edit: Record }>({ items: {}, failed: {}, paused: {}, edit: {}, }), ) createComputed((prev) => { const key = sessionKey() if (key !== prev) { setStore("deferRender", true) requestAnimationFrame(() => { setTimeout(() => setStore("deferRender", false), 0) }) } return key }, sessionKey()) let reviewFrame: number | undefined let refreshFrame: number | undefined let refreshTimer: number | undefined let todoFrame: number | undefined let todoTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined const vcsTask = new Map>() const vcsRun = new Map() const bumpVcs = (mode: VcsMode) => { const next = (vcsRun.get(mode) ?? 0) + 1 vcsRun.set(mode, next) return next } const resetVcs = (mode?: VcsMode) => { const list = mode ? [mode] : (["git", "branch"] as const) list.forEach((item) => { bumpVcs(item) vcsTask.delete(item) setVcs("diff", item, []) setVcs("ready", item, false) }) } const loadVcs = (mode: VcsMode, force = false) => { if (sync.project?.vcs !== "git") return Promise.resolve() if (!force && vcs.ready[mode]) return Promise.resolve() if (force) { if (vcsTask.has(mode)) bumpVcs(mode) vcsTask.delete(mode) setVcs("ready", mode, false) } const current = vcsTask.get(mode) if (current) return current const run = bumpVcs(mode) const task = sdk.client.vcs .diff({ mode }) .then((result) => { if (vcsRun.get(mode) !== run) return setVcs("diff", mode, list(result.data)) setVcs("ready", mode, true) }) .catch((error) => { if (vcsRun.get(mode) !== run) return console.debug("[session-review] failed to load vcs diff", { mode, error }) setVcs("diff", mode, []) setVcs("ready", mode, true) }) .finally(() => { if (vcsTask.get(mode) === task) vcsTask.delete(mode) }) vcsTask.set(mode, task) return task } const refreshVcs = () => { resetVcs() const mode = untrack(vcsMode) if (!mode) return if (!untrack(wantsReview)) return void loadVcs(mode, true) } createComputed((prev) => { const open = desktopReviewOpen() if (prev === undefined || prev === open) return open if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) setUi("reviewSnap", true) reviewFrame = requestAnimationFrame(() => { reviewFrame = undefined setUi("reviewSnap", false) }) return open }, desktopReviewOpen()) const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git") const changesOptions = createMemo(() => { const list: ChangeMode[] = [] if (sync.project?.vcs === "git") list.push("git") if ( sync.project?.vcs === "git" && sync.data.vcs?.branch && sync.data.vcs?.default_branch && sync.data.vcs.branch !== sync.data.vcs.default_branch ) { list.push("branch") } list.push("turn") return list }) const vcsMode = createMemo(() => { if (store.changes === "git" || store.changes === "branch") return store.changes }) const reviewDiffs = createMemo(() => { if (store.changes === "git") return list(vcs.diff.git) if (store.changes === "branch") return list(vcs.diff.branch) return turnDiffs() }) const reviewCount = createMemo(() => reviewDiffs().length) const hasReview = createMemo(() => reviewCount() > 0) const reviewReady = createMemo(() => { if (store.changes === "git") return vcs.ready.git if (store.changes === "branch") return vcs.ready.branch return true }) const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project if (project && sdk.directory !== project.worktree) return sdk.directory return "main" }) const setActiveMessage = (message: UserMessage | undefined) => { messageMark = scrollMark setStore("messageId", message?.id) } const anchor = (id: string) => `message-${id}` const cursor = () => { const root = scroller if (!root) return store.messageId const box = root.getBoundingClientRect() const line = box.top + 100 const list = [...root.querySelectorAll("[data-message-id]")] .map((el) => { const id = el.dataset.messageId if (!id) return const rect = el.getBoundingClientRect() return { id, top: rect.top, bottom: rect.bottom } }) .filter((item): item is { id: string; top: number; bottom: number } => !!item) const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) const hit = shown.find((item) => item.top <= line && item.bottom >= line) if (hit) return hit.id const near = [...shown].sort((a, b) => { 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 })[0] if (near) return near.id return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId } function navigateMessageByOffset(offset: number) { const msgs = visibleUserMessages() if (msgs.length === 0) return const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length const currentIndex = base === -1 ? msgs.length : base const targetIndex = currentIndex + offset if (targetIndex < 0 || targetIndex > msgs.length) return if (targetIndex === msgs.length) { resumeScroll() return } autoScroll.pause() scrollToMessage(msgs[targetIndex], "auto") } function upsert(next: Project) { const list = globalSync.data.project sync.set("project", next.id) const idx = list.findIndex((item) => item.id === next.id) if (idx >= 0) { globalSync.set( "project", list.map((item, i) => (i === idx ? { ...item, ...next } : item)), ) return } const at = list.findIndex((item) => item.id > next.id) if (at >= 0) { globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)]) return } globalSync.set("project", [...list, next]) } const gitMutation = useMutation(() => ({ mutationFn: () => sdk.client.project.initGit(), onSuccess: (x) => { if (!x.data) return upsert(x.data) }, onError: (err) => { showToast({ variant: "error", title: language.t("common.requestFailed"), description: formatServerError(err, language.t), }) }, })) function initGit() { if (gitMutation.isPending) return gitMutation.mutate() } let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let dockHeight = 0 let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined let scrollMark = 0 let messageMark = 0 const scrollGestureWindowMs = 250 const markScrollGesture = (target?: EventTarget | null) => { const root = scroller if (!root) return const el = target instanceof Element ? target : undefined const nested = el?.closest("[data-scrollable]") if (nested && nested !== root) return setUi("scrollGesture", Date.now()) } const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect( on([() => sdk.directory, () => params.id] as const, ([, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined refreshTimer = undefined if (!id) return const cached = untrack(() => sync.data.message[id] !== undefined) const stale = !cached ? false : (() => { const info = getSessionPrefetch(sdk.directory, id) if (!info) return true return Date.now() - info.at > SESSION_PREFETCH_TTL })() untrack(() => { void sync.session.sync(id) }) refreshFrame = requestAnimationFrame(() => { refreshFrame = undefined refreshTimer = window.setTimeout(() => { refreshTimer = undefined if (params.id !== id) return untrack(() => { if (stale) void sync.session.sync(id, { force: true }) }) }, 0) }) }), ) createEffect( on( () => { const id = params.id return [ sdk.directory, id, id ? (sync.data.session_status[id]?.type ?? "idle") : "idle", id ? composer.blocked() : false, ] as const }, ([dir, id, status, blocked]) => { if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) if (todoTimer !== undefined) window.clearTimeout(todoTimer) todoFrame = undefined todoTimer = undefined if (!id) return if (status === "idle" && !blocked) return const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) todoFrame = requestAnimationFrame(() => { todoFrame = undefined todoTimer = window.setTimeout(() => { todoTimer = undefined if (sdk.directory !== dir || params.id !== id) return untrack(() => { void sync.session.todo(id, cached ? { force: true } : undefined) }) }, 0) }) }, { defer: true }, ), ) createEffect( on( () => visibleUserMessages().at(-1)?.id, (lastId, prevLastId) => { if (lastId && prevLastId && lastId > prevLastId) { setStore("messageId", undefined) } }, { defer: true }, ), ) createEffect( on( sessionKey, () => { setStore("messageId", undefined) setStore("changes", "git") setUi("pendingMessage", undefined) }, { defer: true }, ), ) createEffect( on( () => sdk.directory, () => { resetVcs() }, { defer: true }, ), ) createEffect( on( () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const, (next, prev) => { if (prev === undefined || same(next, prev)) return refreshVcs() }, { defer: true }, ), ) const stopVcs = sdk.event.listen((evt) => { if (evt.details.type !== "file.watcher.updated") return const props = typeof evt.details.properties === "object" && evt.details.properties ? (evt.details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return refreshVcs() }) onCleanup(stopVcs) createEffect( on( () => params.dir, (dir) => { if (!dir) return setStore("newSessionWorktree", "main") }, { defer: true }, ), ) const selectionPreview = (path: string, selection: FileSelection) => { const content = file.get(path)?.content?.content if (!content) return undefined return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) } const addCommentToContext = (input: { file: string selection: SelectedLineRange comment: string preview?: string origin?: "review" | "file" }) => { const selection = selectionFromLines(input.selection) const preview = input.preview ?? selectionPreview(input.file, selection) const saved = comments.add({ file: input.file, selection: input.selection, comment: input.comment, }) prompt.context.add({ type: "file", path: input.file, selection, comment: input.comment, commentID: saved.id, commentOrigin: input.origin, preview, }) } const updateCommentInContext = (input: { id: string file: string selection: SelectedLineRange comment: string preview?: string }) => { comments.update(input.file, input.id, input.comment) prompt.context.updateComment(input.file, input.id, { comment: input.comment, ...(input.preview ? { preview: input.preview } : {}), }) } const removeCommentFromContext = (input: { id: string; file: string }) => { comments.remove(input.file, input.id) prompt.context.removeComment(input.file, input.id) } const reviewCommentActions = createMemo(() => ({ moreLabel: language.t("common.moreOptions"), editLabel: language.t("common.edit"), deleteLabel: language.t("common.delete"), saveLabel: language.t("common.save"), })) const isEditableTarget = (target: EventTarget | null | undefined) => { if (!(target instanceof HTMLElement)) return false return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable } const deepActiveElement = () => { let current: Element | null = document.activeElement while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { current = current.shadowRoot.activeElement } return current instanceof HTMLElement ? current : undefined } const handleKeyDown = (event: KeyboardEvent) => { const path = event.composedPath() const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) const activeElement = deepActiveElement() const protectedTarget = path.some( (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, ) if (protectedTarget || isEditableTarget(target)) return if (activeElement) { const isProtected = activeElement.closest("[data-prevent-autofocus]") const isInput = isEditableTarget(activeElement) if (isProtected || isInput) return } if (dialog.active) return if (activeElement === inputRef) { if (event.key === "Escape") inputRef?.blur() return } // Prefer the open terminal over the composer when it can take focus if (view().terminal.opened()) { const id = terminal.active() if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return } // Only treat explicit scroll keys as potential "user scroll" gestures. if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { markScrollGesture() return } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (composer.blocked() || isChildSession()) return inputRef?.focus() } } const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") : store.mobileTab === "changes", ) createEffect(() => { const list = changesOptions() if (list.includes(store.changes)) return const next = list[0] if (!next) return setStore("changes", next) }) createEffect(() => { const mode = vcsMode() if (!mode) return if (!wantsReview()) return void loadVcs(mode) }) createEffect( on( () => sync.data.session_status[params.id ?? ""]?.type, (next, prev) => { const mode = vcsMode() if (!mode) return if (!wantsReview()) return if (next !== "idle" || prev === undefined || prev === "idle") return void loadVcs(mode, true) }, { defer: true }, ), ) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) const [tree, setTree] = createStore({ reviewScroll: undefined as HTMLDivElement | undefined, pendingDiff: undefined as string | undefined, activeDiff: undefined as string | undefined, }) createEffect( on( sessionKey, () => { setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined, }) }, { defer: true }, ), ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return setFileTreeTab("all") } const focusInput = () => { if (isChildSession()) return inputRef?.focus() } useSessionCommands({ navigateMessageByOffset, setActiveMessage, focusInput, review: reviewTab, }) const openReviewFile = createOpenReviewFile({ showAllFiles, tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, loadFile: file.load, }) const changesTitle = () => { if (!canReview()) { return null } const label = (option: ChangeMode) => { if (option === "git") return language.t("ui.sessionReview.title.git") if (option === "branch") return language.t("ui.sessionReview.title.branch") return language.t("ui.sessionReview.title.lastTurn") } return (