diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 69bcbdf7..857cc7d1 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises" import { test, expect } from "../fixtures" import { composerEvent, @@ -900,6 +901,125 @@ test("todo dock restarts completing delay after same-count terminal session swit ) }) +test("e2e composer dock keeps latest turn visible when dock height changes", async ({ page, project, assistant }) => { + const title = `e2e composer scroll dock ${Date.now()}` + const longReply = [ + "Here's the smoke test message counting from 1 to 100:", + "", + "```", + ...Array.from({ length: 100 }, (_, index) => `${index + 1}`), + "```", + "", + "Smoke test complete! This output demonstrates:", + "", + "- 100 lines of sequential numeric output", + "- No files were created or modified", + "- Each number appears on its own line as requested", + ].join("\n") + + await project.open() + await withDockSession( + project.sdk, + title, + async (session) => { + const dock = await todoDock(page, session.id) + await project.gotoSession(session.id) + await assistant.reply(longReply) + + await project.prompt("Write a long visible response for scroll dock testing.") + + await dock.open([ + { content: "first scroll dock task", status: "pending" }, + { content: "second scroll dock task", status: "pending" }, + { content: "third scroll dock task", status: "pending" }, + ]) + + const metrics = await page.evaluate(() => { + const viewport = document.querySelector('[data-component="scroll-viewport"]') + const composer = document.querySelector('[data-component="session-prompt-dock"]') + const last = [...document.querySelectorAll("[data-message-id]")].at(-1) + if (!(viewport instanceof HTMLElement) || !(composer instanceof HTMLElement) || !(last instanceof HTMLElement)) { + return null + } + viewport.scrollTop = viewport.scrollHeight + const walker = document.createTreeWalker(last, NodeFilter.SHOW_TEXT) + let tail: Text | null = null + while (walker.nextNode()) { + const node = walker.currentNode + if (node.textContent?.includes("Each number appears on its own line as requested")) tail = node as Text + } + if (!tail) return null + const range = document.createRange() + range.selectNodeContents(tail) + const composerTop = composer.getBoundingClientRect().top + const lastBottom = last.getBoundingClientRect().bottom + const tailBottom = range.getBoundingClientRect().bottom + range.detach() + return { + scrollTop: viewport.scrollTop, + messageDistance: composerTop - lastBottom, + tailDistance: composerTop - tailBottom, + } + }) + + expect(metrics).not.toBeNull() + expect(metrics!.messageDistance).toBeGreaterThanOrEqual(0) + expect(metrics!.tailDistance).toBeGreaterThanOrEqual(0) + + const before = metrics!.scrollTop + const viewport = page.locator('[data-component="scroll-viewport"]').first() + await viewport.hover() + await page.mouse.wheel(0, -360) + + await expect + .poll(async () => { + return page.evaluate(() => { + const viewport = document.querySelector('[data-component="scroll-viewport"]') + if (!(viewport instanceof HTMLElement)) return 0 + return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop + }) + }) + .toBeGreaterThan(120) + + const distanceBeforeExpansion = await page.evaluate(() => { + const viewport = document.querySelector('[data-component="scroll-viewport"]') + if (!(viewport instanceof HTMLElement)) return 0 + return viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop + }) + + await dock.open([ + { content: "first scroll dock task", status: "pending" }, + { content: "second scroll dock task", status: "pending" }, + { content: "third scroll dock task", status: "pending" }, + { content: "fourth scroll dock task expands height", status: "pending" }, + { content: "fifth scroll dock task expands height", status: "pending" }, + ]) + + let afterUserScroll: { scrollTop: number; distanceFromBottom: number } | null = null + await expect + .poll(async () => { + afterUserScroll = await page.evaluate(() => { + const viewport = document.querySelector('[data-component="scroll-viewport"]') + if (!(viewport instanceof HTMLElement)) return null + return { + scrollTop: viewport.scrollTop, + distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, + } + }) + return afterUserScroll?.distanceFromBottom ?? -1 + }) + .toBeGreaterThanOrEqual(distanceBeforeExpansion - 40) + + expect(afterUserScroll).not.toBeNull() + expect(afterUserScroll!.scrollTop).toBeLessThan(before) + + await mkdir(".artifacts/session-scroll-dock", { recursive: true }) + await page.screenshot({ path: ".artifacts/session-scroll-dock/latest-visible.png" }) + }, + { trackSession: project.trackSession }, + ) +}) + test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => { const questions = [ { diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 72299486..04ac3946 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -101,6 +101,7 @@ beforeAll(async () => { mock.module("@opencode-ai/util/encode", () => ({ base64Encode: (value: string) => value, + checksum: (value: string) => String(value.length), })) mock.module("@/context/local", () => ({ diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c02c05b6..b0504300 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,38 +1,20 @@ -import type { 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, - createResource, createMemo, createEffect, createComputed, + createSignal, on, - onMount, + onCleanup, 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 { useFile } from "@/context/file" import { showToast } from "@opencode-ai/ui/toast" -import { checksum } from "@opencode-ai/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" -import { NewSessionView, SessionHeader } from "@/components/session" import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta" 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" @@ -41,300 +23,31 @@ import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { buildDesktopContext, type DesktopContext } from "@/utils/desktop-context" -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 { SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" -import { - coerceReviewChangeMode, - DEFAULT_REVIEW_CHANGE_MODE, - isVcsReviewMode, - nextReviewModeForSessionChange, - reviewChangeOptions, - reviewDiffsForMode, - reviewModeLabelKey, - type ReviewChangeMode, - type VcsReviewMode, -} from "@/pages/session/review-change-mode" +import { buildDesktopContext } from "@/utils/desktop-context" +import { createSessionComposerState } from "@/pages/session/composer" +import { createSizing } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" -import { - emptyMessages, - emptyUserMessages, - readSessionMessages, - readUserMessages, -} from "@/pages/session/session-messages" -import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { SessionPageComposerRegion } from "@/pages/session/session-composer-region" +import { SessionMainView } from "@/pages/session/session-main-view" import { createSessionRunning, isSessionRunning } from "@/pages/session/session-running-state" -import { SessionSidePanel } from "@/pages/session/session-side-panel" -import { createSessionViewController } from "@/pages/session/session-view-controller" -import { deriveArtifactFiles, nextFilesPanelAutoOpen, type SessionArtifactFile } from "@/pages/session/files-tab-state" -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 { createSessionCommentContext } from "@/pages/session/use-session-comment-context" +import { useSessionDesktopContext } from "@/pages/session/use-session-desktop-context" +import { createSessionFollowups } from "@/pages/session/use-session-followups" +import { useSessionKeyboardFocus } from "@/pages/session/use-session-keyboard-focus" +import { createSessionNewWorktree } from "@/pages/session/use-session-new-worktree" +import { useSessionRefreshEffects } from "@/pages/session/use-session-refresh-effects" +import { createSessionRevert } from "@/pages/session/use-session-revert" +import { createSessionReviewPanel } from "@/pages/session/use-session-review-panel" +import { createSessionReviewState } from "@/pages/session/use-session-review-state" +import { createSessionRouteTabs } from "@/pages/session/use-session-route-tabs" +import { createSessionTimelineData } from "@/pages/session/use-session-timeline-data" +import { createSessionTimelineInteraction } from "@/pages/session/use-session-timeline-interaction" +import { useSessionVcsRefresh } from "@/pages/session/use-session-vcs-refresh" 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" -type FollowupItem = FollowupDraft & { id: string } -type FollowupEdit = Pick -const emptyFollowups: FollowupItem[] = [] - -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() @@ -345,1273 +58,234 @@ export default function Page() { const language = useLanguage() const sdk = useSDK() const settings = useSettings() - const prompt = usePrompt() - const comments = useComments() - const terminal = useTerminal() - const location = useLocation() - const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() - const { params, sessionKey, tabs, view } = useSessionLayout() - // Per Page instance: cleanup below cancels pending retries on unmount. - // Five bounded retries cover transient IPC teardown/order races without spinning forever. - const desktopContextMaxRetries = 5 - let lastDesktopContext = "" - let pendingDesktopContext = "" - let desktopContextRetryTimer: number | undefined - let desktopContextRetryCount = 0 - let disposed = false - - const syncDesktopContext = (context: DesktopContext, serialized: string) => { - if (disposed) return - const setDesktopContext = window.api?.setDesktopContext - if (!setDesktopContext) return - void setDesktopContext(context) - .then(() => { - if (disposed) return - if (pendingDesktopContext !== serialized) return - lastDesktopContext = serialized - pendingDesktopContext = "" - desktopContextRetryCount = 0 - if (desktopContextRetryTimer !== undefined) { - window.clearTimeout(desktopContextRetryTimer) - desktopContextRetryTimer = undefined - } - }) - .catch(() => { - if (disposed) return - if (pendingDesktopContext !== serialized || lastDesktopContext === serialized) return - if (desktopContextRetryCount >= desktopContextMaxRetries) { - pendingDesktopContext = "" - desktopContextRetryCount = 0 - return - } - if (desktopContextRetryTimer !== undefined) window.clearTimeout(desktopContextRetryTimer) - desktopContextRetryCount += 1 - const retryDelay = Math.min(4000, 250 * 2 ** (desktopContextRetryCount - 1)) - desktopContextRetryTimer = window.setTimeout(() => { - desktopContextRetryTimer = undefined - if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return - syncDesktopContext(context, serialized) - }, retryDelay) - }) - } - - createEffect(() => { - if (!window.api?.setDesktopContext) return - const context = buildDesktopContext({ - directory: sdk.directory, - sessionID: params.id ?? null, - route: `${location.pathname}${location.search}${location.hash}`, - locale: language.locale(), - }) - const serialized = JSON.stringify(context) - if (serialized === lastDesktopContext || serialized === pendingDesktopContext) return - pendingDesktopContext = serialized - desktopContextRetryCount = 0 - syncDesktopContext(context, serialized) - }) - - 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, - scrollGesture: 0, - scroll: { - overflow: false, - bottom: true, - jump: false, - }, - }) - - 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 desktopSidePanelOpen = createMemo(() => isDesktop() && view().sidePanel.opened()) - const centered = createMemo(() => isDesktop()) - - 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 = () => { - view().sidePanel.openTab("review") - } - - 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 openedTabs = tabState.openedTabs - const activeTab = tabState.activeTab - const activeFileTab = tabState.activeFileTab - const messagesReady = createMemo(() => { - const id = params.id - if (!id) return true - return sync.data.message[id] !== undefined - }) - const sessionView = createSessionViewController({ - directory: () => params.dir ?? "", - routeSessionID: () => params.id, - routeMessagesReady: messagesReady, - }) - const timelineSessionID = sessionView.visible.id - const timelineSessionKey = sessionView.visible.key - const timelineInfo = createMemo(() => { - const id = timelineSessionID() - if (!id) return - return sync.session.get(id) - }) - const timelineIsChildSession = createMemo(() => !!timelineInfo()?.parentID) - const composer = createSessionComposerState({ sessionID: timelineSessionID }) - const timelineMessages = createMemo( - () => { - const id = timelineSessionID() - return readSessionMessages(id ? sync.data.message[id] : undefined) - }, - emptyMessages, - { equals: same }, - ) - const timelineMessagesReady = sessionView.visible.ready - const timelineDiffs = createMemo(() => { - const id = timelineSessionID() - if (!id) return [] - return list(sync.data.session_diff[id]) - }) - const timelineUserMessages = createMemo(() => readUserMessages(timelineMessages()), emptyUserMessages, { - equals: same, - }) - const timelineRevertMessageID = createMemo(() => { - const id = timelineSessionID() - if (!id) return - return sync.session.get(id)?.revert?.messageID - }) - const timelineVisibleUserMessages = createMemo( - () => { - const revert = timelineRevertMessageID() - if (!revert) return timelineUserMessages() - return timelineUserMessages().filter((m) => m.id < revert) - }, - emptyUserMessages, - { - equals: same, - }, - ) - const timelineHistoryMore = createMemo(() => { - const id = timelineSessionID() - if (!id) return false - return sync.session.history.more(id) - }) - const timelineHistoryLoading = createMemo(() => { - const id = timelineSessionID() - if (!id) return false - return sync.session.history.loading(id) - }) - 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 lastUserMessage = createMemo(() => timelineVisibleUserMessages().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: DEFAULT_REVIEW_CHANGE_MODE as ReviewChangeMode, - newSessionWorktree: "main", - deferRender: false, - }) - - const [vcs, setVcs] = createStore<{ - diff: Record - ready: Record - }>({ - diff: { - unstaged: [] as VcsFileDiff[], - staged: [] as VcsFileDiff[], - branch: [] as VcsFileDiff[], - }, - ready: { - unstaged: false, - staged: 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 = timelineSessionKey() - if (key !== prev) { - setStore("deferRender", true) - requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) - }) - } - return key - }, timelineSessionKey()) - - 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: VcsReviewMode) => { - const next = (vcsRun.get(mode) ?? 0) + 1 - vcsRun.set(mode, next) - return next - } - - const resetVcs = (mode?: VcsReviewMode) => { - const modes = mode ? [mode] : (["unstaged", "staged", "branch"] as const) - modes.forEach((item) => { - bumpVcs(item) - vcsTask.delete(item) - setVcs("diff", item, []) - setVcs("ready", item, false) - }) - } - - const loadVcs = (mode: VcsReviewMode, 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) - } - - const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) - const [artifactHistory, { refetch: refetchArtifactHistory }] = createResource( - timelineSessionID, - async (sessionID) => ({ - sessionID, - artifacts: await sdk.client.session - .artifacts({ sessionID }) - .then((res) => res.data ?? []) - .catch(() => []), - }), - { initialValue: { sessionID: "", artifacts: [] as SessionArtifactFile[] } }, - ) - const artifactFiles = createMemo(() => { - const sessionID = timelineSessionID() - const history = artifactHistory.latest - if (history?.sessionID === sessionID && history.artifacts.length > 0) { - return deriveArtifactFiles(sdk.directory, history.artifacts) - } - - return deriveArtifactFiles( - sdk.directory, - turnDiffs().flatMap((diff) => { - if (diff.status !== "added" && diff.status !== "modified") return [] - return [{ file: diff.file, kind: diff.status as "added" | "modified" }] - }), - ) - }) - const changesOptions = createMemo(() => - reviewChangeOptions({ isGit: sync.project?.vcs === "git" }), - ) - const vcsMode = createMemo(() => { - if (isVcsReviewMode(store.changes)) return store.changes - }) - const reviewDiffs = createMemo(() => { - return list( - reviewDiffsForMode(store.changes, { - turn: turnDiffs(), - vcs: vcs.diff, - }), - ) - }) - const reviewCount = createMemo(() => reviewDiffs().length) - const hasReview = createMemo(() => reviewCount() > 0) - const reviewReady = createMemo(() => { - if (isVcsReviewMode(store.changes)) return vcs.ready[store.changes] - 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 = timelineVisibleUserMessages() - 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") - } - - 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 = timelineSessionID() - 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 || timelineSessionID() !== id) return - untrack(() => { - void sync.session.todo(id, cached ? { force: true } : undefined) - }) - }, 0) - }) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => timelineVisibleUserMessages().at(-1)?.id, - (lastId, prevLastId) => { - if (lastId && prevLastId && lastId > prevLastId) { - setStore("messageId", undefined) - } - }, - { defer: true }, - ), - ) - - createEffect( - on( - sessionKey, - () => { - setStore("messageId", undefined) - setStore("changes", nextReviewModeForSessionChange()) - 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() || timelineIsChildSession()) return - inputRef?.focus() - } - } - - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopSidePanelOpen() && view().sidePanel.tab() === "review" && activeTab() === "review" - : store.mobileTab === "changes", - ) - - createEffect(() => { - if (!timelineSessionID()) return - turnDiffs() - void refetchArtifactHistory() - }) - - createEffect(() => { - const id = timelineSessionID() - if (!id) return - if (sync.data.session_diff[id] === undefined) return - void refetchArtifactHistory() - }) - - createEffect(() => { - if (!timelineSessionID()) return - - // Use Snapshot diffs (SSE-pushed, authoritative) with turnDiffs as fallback - // for reopened sessions where session_diff hasn't been fetched yet. - const source = timelineDiffs().length > 0 ? timelineDiffs() : turnDiffs() - const next = nextFilesPanelAutoOpen( - { - seenAdded: view().sidePanel.filesAutoOpenSeen(), - dismissed: view().sidePanel.filesAutoOpenDismissed(), - }, - source, - ) - - if (next.open) { - view().sidePanel.setTab("files") - view().sidePanel.open() - } - view().sidePanel.setAutoOpenState(next) - }) - - createEffect(() => { - const list = changesOptions() - const next = coerceReviewChangeMode(store.changes, list) - if (next === store.changes) 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 = () => view().sidePanel.explorer.tab() - const setFileTreeTab = (value: "changes" | "all") => view().sidePanel.explorer.setTab(value) + const prompt = usePrompt() + const comments = useComments() + const terminal = useTerminal() + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() + const { params, sessionKey, tabs, view } = useSessionLayout() - const [tree, setTree] = createStore({ - reviewScroll: undefined as HTMLDivElement | undefined, - pendingDiff: undefined as string | undefined, - activeDiff: undefined as string | undefined, + useSessionDesktopContext({ + context: () => + buildDesktopContext({ + directory: sdk.directory, + sessionID: params.id ?? null, + route: `${location.pathname}${location.search}${location.hash}`, + locale: language.locale(), + }), + send: window.api?.setDesktopContext, }) createEffect( on( - sessionKey, - () => { - setTree({ - reviewScroll: undefined, - pendingDiff: undefined, - activeDiff: undefined, + () => [prompt.ready(), params.id, searchParams.prompt] as const, + ([ready, sessionID, text]) => { + if (!ready || sessionID || !text) return + untrack(() => { + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) }) }, - { defer: true }, ), ) - const showAllFiles = () => { - if (fileTreeTab() !== "changes") return - setFileTreeTab("all") - } - - const focusInput = () => { - if (timelineIsChildSession()) return - inputRef?.focus() - } + const isDesktop = createMediaQuery("(min-width: 768px)") + const size = createSizing() + const desktopSidePanelOpen = createMemo(() => isDesktop() && view().sidePanel.opened()) + const centered = createMemo(() => isDesktop()) - useSessionCommands({ - navigateMessageByOffset, - setActiveMessage, - focusInput, - review: reviewTab, + const timeline = createSessionTimelineData({ + directory: () => params.dir ?? "", + routeSessionID: () => params.id, + sync, + local, }) - - const openReviewFile = createOpenReviewFile({ - showAllFiles, + const canReview = createMemo(() => !!sync.project) + const reviewTab = createMemo(() => isDesktop()) + const tabState = createSessionRouteTabs({ + directory: () => params.dir ?? "", + sessionID: () => params.id, + layout, + tabs, + pathFromTab: file.pathFromTab, tabForPath: file.tab, - openTab: tabs().open, - setActive: tabs().setActive, - loadFile: file.load, + review: reviewTab, + hasReview: canReview, }) + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab + const timelineSessionID = timeline.sessionID + const timelineSessionKey = timeline.sessionKey + const timelineIsChildSession = timeline.isChildSession + const composer = createSessionComposerState({ sessionID: timelineSessionID }) + const timelineMessages = timeline.messages + const timelineMessagesReady = timeline.messagesReady + const timelineDiffs = timeline.diffs + const timelineUserMessages = timeline.userMessages + const timelineRevertMessageID = timeline.revertMessageID + const timelineVisibleUserMessages = timeline.visibleUserMessages + const timelineHistoryMore = timeline.historyMore + const timelineHistoryLoading = timeline.historyLoading + const lastUserMessage = timeline.lastUserMessage - const changesTitle = () => { - if (!canReview()) { - return null - } - - const label = (option: ReviewChangeMode) => language.t(reviewModeLabelKey(option)) - - return ( - option && input.reviewState.setChanges(option)} + variant="ghost" + size="small" + valueClass="text-13-medium" + /> + ) + } + + const empty = (text: string) => ( +
+
{text}
+
+ ) + + const reviewEmptyText = createMemo(() => { + const changes = input.reviewState.changes() + if (changes === "unstaged") return input.language.t("session.review.noUnstagedChanges") + if (changes === "staged") return input.language.t("session.review.noStagedChanges") + if (changes === "branch") return input.language.t("session.review.noBranchChanges") + return input.language.t("session.review.noChanges") + }) + + const reviewEmpty = (emptyInput: { loadingClass: string; emptyClass: string }) => { + const changes = input.reviewState.changes() + if (isVcsReviewMode(changes)) { + if (!input.reviewState.reviewReady()) { + return
{input.language.t("session.review.loadingChanges")}
+ } + return empty(reviewEmptyText()) + } + + if (changes === "turn") return empty(reviewEmptyText()) + + return ( +
+
{reviewEmptyText()}
+
+ ) + } + + const reviewCommentActions = createMemo(() => ({ + moreLabel: input.language.t("common.moreOptions"), + editLabel: input.language.t("common.edit"), + deleteLabel: input.language.t("common.delete"), + saveLabel: input.language.t("common.save"), + })) + + const reviewContent = (contentInput: { + classes?: SessionReviewTabProps["classes"] + loadingClass: string + emptyClass: string + }): JSX.Element => ( + + input.commentContext.add({ ...comment, origin: "review" })} + onLineCommentUpdate={input.commentContext.update} + onLineCommentDelete={input.commentContext.remove} + lineCommentActions={reviewCommentActions()} + commentMentions={{ + items: input.file.searchFilesAndDirectories, + }} + comments={input.comments.all()} + focusedComment={input.comments.focus()} + onFocusedCommentChange={input.comments.setFocus} + onViewFile={input.onViewFile} + classes={contentInput.classes} + /> + + ) + + const reviewPanel = () => ( +
+
+ {reviewContent({ + loadingClass: "px-6 py-4 text-text-weak", + emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", + })} +
+
+ ) + + const mobileFallback = () => + reviewContent({ + classes: { + root: "pb-8", + header: "px-4", + container: "px-4", + }, + loadingClass: "px-4 py-4 text-text-weak", + emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", + }) + + return { + reviewContent, + reviewPanel, + mobileFallback, + } +} diff --git a/packages/app/src/pages/session/session-composer-region.tsx b/packages/app/src/pages/session/session-composer-region.tsx new file mode 100644 index 00000000..fd199a66 --- /dev/null +++ b/packages/app/src/pages/session/session-composer-region.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from "solid-js" +import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta" +import { SessionComposerRegion, type createSessionComposerState } from "@/pages/session/composer" + +type ComposerRegionProps = ComponentProps + +export function SessionPageComposerRegion(props: { + variant: "session" | "home" + state: ReturnType + ready: boolean + displaySessionID?: string + displaySessionKey?: string + centered: boolean + inputRef: (el: HTMLDivElement) => void + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + onResponseSubmit: () => void + onModeChange?: (mode: "normal" | "shell") => void + selectedSkill?: () => PawworkSkillName | undefined + followup?: ComposerRegionProps["followup"] + revert?: ComposerRegionProps["revert"] + setPromptDockRef: (el: HTMLDivElement) => void +}) { + return +} diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx new file mode 100644 index 00000000..035c8042 --- /dev/null +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -0,0 +1,147 @@ +import { Match, Show, Switch, type ComponentProps, type JSX } from "solid-js" +import { Tabs } from "@opencode-ai/ui/tabs" +import { NewSessionView, SessionHeader } from "@/components/session" +import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta" +import type { useLanguage } from "@/context/language" +import type { createSizing } from "@/pages/session/helpers" +import { MessageTimeline } from "@/pages/session/message-timeline" +import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { TerminalPanel } from "@/pages/session/terminal-panel" +import type { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" +import type { createSessionReviewState } from "@/pages/session/use-session-review-state" +import type { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" + +type TimelineProps = ComponentProps + +export function SessionMainView(props: { + activeSessionID?: string + isDesktop: boolean + mobileTab: "session" | "changes" + setMobileTab: (tab: "session" | "changes") => void + language: ReturnType + timelineSessionID?: string + timelineSessionKey: string + timelineMessages: TimelineProps["sessionMessages"] + mobileChanges: boolean + mobileFallback: JSX.Element + actions: TimelineProps["actions"] + scroll: ReturnType["scroll"] + resumeScroll: () => void + setScrollRef: TimelineProps["setScrollRef"] + scheduleScrollState: TimelineProps["onScheduleScrollState"] + autoScroll: ReturnType["autoScroll"] + markScrollGesture: TimelineProps["onMarkScrollGesture"] + hasScrollGesture: TimelineProps["hasScrollGesture"] + markUserScroll: TimelineProps["onUserScroll"] + historyWindow: ReturnType + centered: boolean + setContentRef: TimelineProps["setContentRef"] + historyMore: boolean + historyLoading: boolean + anchor: TimelineProps["anchor"] + composerSession: JSX.Element + composerHome: (ctx: { + onModeChange: (mode: "normal" | "shell") => void + selectedSkill: () => PawworkSkillName | undefined + }) => JSX.Element + canReview: () => boolean + reviewDiffs: ReturnType["reviewDiffs"] + hasReview: ReturnType["hasReview"] + reviewCount: ReturnType["reviewCount"] + reviewPanel: () => JSX.Element + files: ReturnType["artifactFiles"] + size: ReturnType +}) { + return ( +
+ +
+ + + + props.setMobileTab("session")} + > + {props.language.t("session.tab.session")} + + props.setMobileTab("changes")} + > + {props.hasReview() + ? props.language.t("session.review.filesChanged", { count: props.reviewCount() }) + : props.language.t("session.review.change.other")} + + + + + +
+
+ + + {(sessionID) => ( + { + void props.historyWindow.loadAndReveal() + }} + renderedUserMessages={props.historyWindow.renderedUserMessages()} + anchor={props.anchor} + /> + )} + + + + + +
+ + +
+ {props.composerSession} +
+ + } + size={props.size} + /> +
+ + + + +
+ ) +} diff --git a/packages/app/src/pages/session/use-session-active-message.ts b/packages/app/src/pages/session/use-session-active-message.ts new file mode 100644 index 00000000..2941ee0a --- /dev/null +++ b/packages/app/src/pages/session/use-session-active-message.ts @@ -0,0 +1,129 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, on } from "solid-js" +import { createStore } from "solid-js/store" + +export function createSessionActiveMessage(input: { + sessionKey: () => string + visibleUserMessages: () => UserMessage[] + lastUserMessageID: () => string | undefined + scroller: () => HTMLElement | undefined + resumeScroll: () => void + pauseAutoScroll: () => void +}) { + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + pendingMessage: undefined as string | undefined, + scrollGesture: 0, + }) + let scrollMark = 0 + let messageMark = 0 + let scrollToMessage: (message: UserMessage, behavior: ScrollBehavior) => void = () => {} + + const setActiveMessage = (message: UserMessage | undefined) => { + messageMark = scrollMark + setStore("messageId", message?.id) + } + + const cursor = () => { + const root = input.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 + } + + const navigateMessageByOffset = (offset: number) => { + const msgs = input.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) { + input.resumeScroll() + return + } + + input.pauseAutoScroll() + scrollToMessage(msgs[targetIndex], "auto") + } + + const markScrollGesture = (target?: EventTarget | null) => { + const root = input.scroller() + if (!root) return + + const el = target instanceof Element ? target : undefined + const nested = el?.closest("[data-scrollable]") + if (nested && nested !== root) return + + setStore("scrollGesture", Date.now()) + } + + const hasScrollGesture = () => Date.now() - store.scrollGesture < 250 + + createEffect( + on( + input.lastUserMessageID, + (lastId, prevLastId) => { + if (lastId && prevLastId && lastId > prevLastId) { + setStore("messageId", undefined) + } + }, + { defer: true }, + ), + ) + + createEffect( + on( + input.sessionKey, + () => { + setStore("messageId", undefined) + setStore("pendingMessage", undefined) + }, + { defer: true }, + ), + ) + + return { + messageId: () => store.messageId, + pendingMessage: () => store.pendingMessage, + setPendingMessage: (value: string | undefined) => setStore("pendingMessage", value), + setActiveMessage, + clearActiveMessage: () => setStore("messageId", undefined), + navigateMessageByOffset, + markScrollGesture, + hasScrollGesture, + markUserScroll: () => { + scrollMark += 1 + }, + setScrollToMessage: (next: (message: UserMessage, behavior: ScrollBehavior) => void) => { + scrollToMessage = next + }, + } +} diff --git a/packages/app/src/pages/session/use-session-comment-context.test.ts b/packages/app/src/pages/session/use-session-comment-context.test.ts new file mode 100644 index 00000000..aed528fb --- /dev/null +++ b/packages/app/src/pages/session/use-session-comment-context.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test" +import { createSessionCommentContext } from "./use-session-comment-context" + +describe("session comment context", () => { + test("adds comments with preview from selected file content", () => { + const added: unknown[] = [] + const promptAdds: unknown[] = [] + const controller = createSessionCommentContext({ + attachmentLabel: () => "Attachment", + getFileContent: () => "one\ntwo\nthree\n", + comments: { + add(input) { + added.push(input) + return { id: "c1" } + }, + update() {}, + remove() {}, + }, + promptContext: { + add(input) { + promptAdds.push(input) + }, + updateComment() {}, + removeComment() {}, + }, + }) + + controller.add({ + file: "src/a.ts", + selection: { start: 2, end: 2 }, + comment: "check this", + origin: "review", + }) + + expect(added).toEqual([{ file: "src/a.ts", selection: { start: 2, end: 2 }, comment: "check this" }]) + expect(promptAdds[0]).toMatchObject({ + type: "file", + path: "src/a.ts", + comment: "check this", + commentID: "c1", + commentOrigin: "review", + preview: "two", + }) + }) + + test("updates and removes prompt comment context", () => { + const updated: unknown[] = [] + const removed: unknown[] = [] + const controller = createSessionCommentContext({ + attachmentLabel: () => "Attachment", + getFileContent: () => undefined, + comments: { + add() { + return { id: "unused" } + }, + update(file, id, comment) { + updated.push({ file, id, comment }) + }, + remove(file, id) { + removed.push({ file, id }) + }, + }, + promptContext: { + add() {}, + updateComment(file, id, patch) { + updated.push({ file, id, patch }) + }, + removeComment(file, id) { + removed.push({ file, id }) + }, + }, + }) + + controller.update({ id: "c1", file: "src/a.ts", selection: { start: 1, end: 1 }, comment: "new", preview: "one" }) + controller.update({ id: "c2", file: "src/b.ts", selection: { start: 1, end: 1 }, comment: "blank", preview: "" }) + controller.remove({ id: "c1", file: "src/a.ts" }) + + expect(updated).toContainEqual({ file: "src/a.ts", id: "c1", comment: "new" }) + expect(updated).toContainEqual({ file: "src/a.ts", id: "c1", patch: { comment: "new", preview: "one" } }) + expect(updated).toContainEqual({ file: "src/b.ts", id: "c2", patch: { comment: "blank", preview: "" } }) + expect(removed).toEqual([ + { file: "src/a.ts", id: "c1" }, + { file: "src/a.ts", id: "c1" }, + ]) + }) +}) diff --git a/packages/app/src/pages/session/use-session-comment-context.ts b/packages/app/src/pages/session/use-session-comment-context.ts new file mode 100644 index 00000000..5cb88ef7 --- /dev/null +++ b/packages/app/src/pages/session/use-session-comment-context.ts @@ -0,0 +1,69 @@ +import { selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file/types" +import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" + +export function createSessionCommentContext(input: { + attachmentLabel: () => string + getFileContent: (path: string) => string | undefined + comments: { + add: (comment: { file: string; selection: SelectedLineRange; comment: string }) => { id: string } + update: (file: string, id: string, comment: string) => void + remove: (file: string, id: string) => void + } + promptContext: { + add: (entry: { + type: "file" + path: string + selection: FileSelection + comment: string + commentID: string + commentOrigin?: "review" | "file" + preview?: string + }) => void + updateComment: (file: string, id: string, patch: { comment: string; preview?: string }) => void + removeComment: (file: string, id: string) => void + } +}) { + const selectionPreview = (path: string, selection: FileSelection) => { + const content = input.getFileContent(path) + if (!content) return undefined + return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) + } + + return { + add(comment: { + file: string + selection: SelectedLineRange + comment: string + preview?: string + origin?: "review" | "file" + }) { + const selection = selectionFromLines(comment.selection) + const preview = comment.preview ?? selectionPreview(comment.file, selection) + const saved = input.comments.add({ + file: comment.file, + selection: comment.selection, + comment: comment.comment, + }) + input.promptContext.add({ + type: "file", + path: comment.file, + selection, + comment: comment.comment, + commentID: saved.id, + commentOrigin: comment.origin, + preview, + }) + }, + update(comment: { id: string; file: string; selection: SelectedLineRange; comment: string; preview?: string }) { + input.comments.update(comment.file, comment.id, comment.comment) + input.promptContext.updateComment(comment.file, comment.id, { + comment: comment.comment, + ...(comment.preview !== undefined ? { preview: comment.preview } : {}), + }) + }, + remove(comment: { id: string; file: string }) { + input.comments.remove(comment.file, comment.id) + input.promptContext.removeComment(comment.file, comment.id) + }, + } +} diff --git a/packages/app/src/pages/session/use-session-desktop-context.test.ts b/packages/app/src/pages/session/use-session-desktop-context.test.ts new file mode 100644 index 00000000..a8857aff --- /dev/null +++ b/packages/app/src/pages/session/use-session-desktop-context.test.ts @@ -0,0 +1,71 @@ +import type { DesktopContext } from "@/utils/desktop-context" +import { describe, expect, test } from "bun:test" +import { createDesktopContextSync } from "./use-session-desktop-context" + +const context: DesktopContext = { + directory: "/repo", + sessionID: "ses_1", + route: "/repo/session/ses_1", + locale: "en", + title: "PawWork", +} + +describe("desktop context sync", () => { + test("does nothing when no sender exists", () => { + const sync = createDesktopContextSync({ + maxRetries: 5, + send: undefined, + setTimer: () => { + throw new Error("timer should not be scheduled") + }, + clearTimer: () => undefined, + }) + + sync.push(context) + sync.dispose() + }) + + test("deduplicates identical pending context", () => { + let calls = 0 + const sync = createDesktopContextSync({ + maxRetries: 5, + send: async () => { + calls += 1 + }, + setTimer: () => 1, + clearTimer: () => undefined, + }) + + sync.push(context) + sync.push(context) + + expect(calls).toBe(1) + sync.dispose() + }) + + test("schedules retry after a failed send", async () => { + const timers: Array<() => void> = [] + let calls = 0 + const sync = createDesktopContextSync({ + maxRetries: 5, + send: async () => { + calls += 1 + throw new Error("transient failure") + }, + setTimer: (fn) => { + timers.push(fn) + return timers.length + }, + clearTimer: () => undefined, + }) + + sync.push(context) + await Promise.resolve() + await Promise.resolve() + + expect(calls).toBe(1) + expect(timers).toHaveLength(1) + + sync.dispose() + }) +}) diff --git a/packages/app/src/pages/session/use-session-desktop-context.ts b/packages/app/src/pages/session/use-session-desktop-context.ts new file mode 100644 index 00000000..1f86db5a --- /dev/null +++ b/packages/app/src/pages/session/use-session-desktop-context.ts @@ -0,0 +1,85 @@ +import { createEffect, onCleanup } from "solid-js" +import type { DesktopContext } from "@/utils/desktop-context" + +export type DesktopContextSender = (context: DesktopContext) => Promise + +export function createDesktopContextSync(input: { + maxRetries: number + send: DesktopContextSender | undefined + setTimer: (fn: () => void, delay: number) => number + clearTimer: (id: number) => void +}) { + let lastDesktopContext = "" + let pendingDesktopContext = "" + let desktopContextRetryTimer: number | undefined + let desktopContextRetryCount = 0 + let disposed = false + + const clear = () => { + if (desktopContextRetryTimer !== undefined) { + input.clearTimer(desktopContextRetryTimer) + desktopContextRetryTimer = undefined + } + } + + const sync = (context: DesktopContext, serialized: string) => { + if (disposed || !input.send) return + void input + .send(context) + .then(() => { + if (disposed || pendingDesktopContext !== serialized) return + lastDesktopContext = serialized + pendingDesktopContext = "" + desktopContextRetryCount = 0 + clear() + }) + .catch(() => { + if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + if (desktopContextRetryCount >= input.maxRetries) { + pendingDesktopContext = "" + desktopContextRetryCount = 0 + return + } + clear() + desktopContextRetryCount += 1 + const retryDelay = Math.min(4000, 250 * 2 ** (desktopContextRetryCount - 1)) + desktopContextRetryTimer = input.setTimer(() => { + desktopContextRetryTimer = undefined + if (disposed || pendingDesktopContext !== serialized || lastDesktopContext === serialized) return + sync(context, serialized) + }, retryDelay) + }) + } + + return { + push(context: DesktopContext) { + const serialized = JSON.stringify(context) + if (serialized === lastDesktopContext || serialized === pendingDesktopContext) return + pendingDesktopContext = serialized + desktopContextRetryCount = 0 + sync(context, serialized) + }, + dispose() { + disposed = true + clear() + }, + } +} + +export function useSessionDesktopContext(input: { + context: () => DesktopContext + send: DesktopContextSender | undefined +}) { + const sync = createDesktopContextSync({ + maxRetries: 5, + send: input.send, + setTimer: (fn, delay) => window.setTimeout(fn, delay), + clearTimer: (id) => window.clearTimeout(id), + }) + + createEffect(() => { + sync.push(input.context()) + }) + + onCleanup(sync.dispose) +} diff --git a/packages/app/src/pages/session/use-session-followups.test.ts b/packages/app/src/pages/session/use-session-followups.test.ts new file mode 100644 index 00000000..6db3a93b --- /dev/null +++ b/packages/app/src/pages/session/use-session-followups.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, describe, expect, mock, test } from "bun:test" +import type { FollowupDraft } from "@/components/prompt-input/submit" +import type { followupPreviewText as PreviewText, shouldAutoSendFollowup as ShouldAutoSend } from "./use-session-followups" + +let followupPreviewText: typeof PreviewText +let shouldAutoSendFollowup: typeof ShouldAutoSend + +const draft = (input: Pick): FollowupDraft => ({ + sessionID: "ses_1", + sessionDirectory: "/repo", + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + ...input, +}) + +beforeAll(async () => { + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + })) + mock.module("@opencode-ai/util/encode", () => ({ + base64Decode: (value: string) => value, + base64Encode: (value: string) => value, + checksum: (value: string) => String(value.length), + })) + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "web" }), + })) + + const mod = await import("./use-session-followups") + followupPreviewText = mod.followupPreviewText + shouldAutoSendFollowup = mod.shouldAutoSendFollowup +}) + +describe("session followups", () => { + test("uses first non-empty text line as dock preview", () => { + expect( + followupPreviewText({ + attachmentLabel: "Attachment", + item: draft({ + prompt: [{ type: "text", content: "\n run tests\nmore", start: 0, end: 17 }], + context: [], + }), + }), + ).toBe("run tests") + }) + + test("falls back to attachment label when prompt has no visible text", () => { + expect( + followupPreviewText({ + attachmentLabel: "Attachment", + item: draft({ + prompt: [], + context: [], + }), + }), + ).toBe("[Attachment]") + }) + + test("auto-send is blocked by busy, failure, pause, child session, permission block, or active mutation", () => { + const base = { + hasSession: true, + hasItem: true, + busy: false, + failed: false, + paused: false, + childSession: false, + blocked: false, + followupBusy: false, + } + + expect(shouldAutoSendFollowup(base)).toBe(true) + expect(shouldAutoSendFollowup({ ...base, hasSession: false })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, hasItem: false })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, busy: true })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, failed: true })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, paused: true })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, childSession: true })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, blocked: true })).toBe(false) + expect(shouldAutoSendFollowup({ ...base, followupBusy: true })).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/use-session-followups.ts b/packages/app/src/pages/session/use-session-followups.ts new file mode 100644 index 00000000..4459ef95 --- /dev/null +++ b/packages/app/src/pages/session/use-session-followups.ts @@ -0,0 +1,225 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useMutation } from "@tanstack/solid-query" +import type { FollowupDraft } from "@/components/prompt-input/submit" +import { sendFollowupDraft } from "@/components/prompt-input/submit" +import type { useGlobalSync } from "@/context/global-sync" +import type { useSDK } from "@/context/sdk" +import type { useSettings } from "@/context/settings" +import type { useSync } from "@/context/sync" +import { Identifier } from "@/utils/id" +import { Persist, persisted } from "@/utils/persist" + +export type FollowupItem = FollowupDraft & { id: string } +export type FollowupEdit = Pick +export const emptyFollowups: FollowupItem[] = [] + +export function followupPreviewText(input: { + item: FollowupDraft + attachmentLabel: string +}) { + const text = input.item.prompt + .map((part) => { + if (part.type === "image") return `[image:${part.filename}]` + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + return part.content + }) + .join("") + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => !!line) + + return text || `[${input.attachmentLabel}]` +} + +export function shouldAutoSendFollowup(input: { + hasSession: boolean + hasItem: boolean + busy: boolean + failed: boolean + paused: boolean + childSession: boolean + blocked: boolean + followupBusy: boolean +}) { + return ( + input.hasSession && + input.hasItem && + !input.busy && + !input.failed && + !input.paused && + !input.childSession && + !input.blocked && + !input.followupBusy + ) +} + +export function createSessionFollowups(input: { + directory: string + client: ReturnType["client"] + sessionID: () => string | undefined + isChildSession: () => boolean + busy: () => boolean + blocked: () => boolean + settings: ReturnType + sync: ReturnType + globalSync: ReturnType + fail: (err: unknown) => void + resumeScroll: () => void + attachmentLabel: () => string +}) { + const [followup, setFollowup] = persisted( + Persist.workspace(input.directory, "followup", ["followup.v1"]), + createStore<{ + items: Record + failed: Record + paused: Record + edit: Record + }>({ + items: {}, + failed: {}, + paused: {}, + edit: {}, + }), + ) + + const queuedFollowups = createMemo(() => { + const id = input.sessionID() + if (!id) return emptyFollowups + return followup.items[id] ?? emptyFollowups + }) + + const editingFollowup = createMemo(() => { + const id = input.sessionID() + if (!id) return + return followup.edit[id] + }) + + const followupMutation = useMutation(() => ({ + mutationFn: async (params: { sessionID: string; id: string; manual?: boolean }) => { + const item = (followup.items[params.sessionID] ?? []).find((entry) => entry.id === params.id) + if (!item) return + + if (params.manual) setFollowup("paused", params.sessionID, undefined) + setFollowup("failed", params.sessionID, undefined) + + const ok = await sendFollowupDraft({ + client: input.client, + sync: input.sync, + globalSync: input.globalSync, + draft: item, + optimisticBusy: item.sessionDirectory === input.directory, + }).catch((err) => { + setFollowup("failed", params.sessionID, params.id) + input.fail(err) + return false + }) + if (!ok) return + + setFollowup("items", params.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== params.id)) + if (params.manual) input.resumeScroll() + }, + })) + + const followupBusy = (sessionID: string) => + followupMutation.isPending && followupMutation.variables?.sessionID === sessionID + + const sendingFollowup = createMemo(() => { + const id = input.sessionID() + if (!id) return + if (!followupBusy(id)) return + return followupMutation.variables?.id + }) + + const queueEnabled = createMemo(() => { + const id = input.sessionID() + if (!id) return false + return input.settings.general.followup() === "queue" && input.busy() && !input.blocked() && !input.isChildSession() + }) + + const queueFollowup = (draft: FollowupDraft) => { + setFollowup("items", draft.sessionID, (items) => [ + ...(items ?? []), + { id: Identifier.ascending("message"), ...draft }, + ]) + setFollowup("failed", draft.sessionID, undefined) + setFollowup("paused", draft.sessionID, undefined) + } + + const followupDock = createMemo(() => + queuedFollowups().map((item) => ({ + id: item.id, + text: followupPreviewText({ item, attachmentLabel: input.attachmentLabel() }), + })), + ) + + const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { + if (input.sync.session.get(sessionID)?.parentID) return Promise.resolve() + const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) + if (!item) return Promise.resolve() + if (followupBusy(sessionID)) return Promise.resolve() + + return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) + } + + const editFollowup = (id: string) => { + const sessionID = input.sessionID() + if (!sessionID) return + if (followupBusy(sessionID)) return + + const item = queuedFollowups().find((entry) => entry.id === id) + if (!item) return + + setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) + setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) + setFollowup("edit", sessionID, { + id: item.id, + prompt: item.prompt, + context: item.context, + }) + } + + const clearFollowupEdit = () => { + const id = input.sessionID() + if (!id) return + setFollowup("edit", id, undefined) + } + + createEffect(() => { + const sessionID = input.sessionID() + const item = queuedFollowups()[0] + + if ( + !shouldAutoSendFollowup({ + hasSession: !!sessionID, + hasItem: !!item, + busy: input.busy(), + failed: !!(sessionID && item && followup.failed[sessionID] === item.id), + paused: !!(sessionID && followup.paused[sessionID]), + childSession: input.isChildSession(), + blocked: input.blocked(), + followupBusy: !!(sessionID && followupBusy(sessionID)), + }) + ) { + return + } + + void sendFollowup(sessionID!, item!.id) + }) + + return { + queueEnabled, + followupDock, + queuedFollowups, + editingFollowup, + sendingFollowup, + queueFollowup, + sendFollowup, + editFollowup, + clearFollowupEdit, + pause(sessionID: string) { + setFollowup("paused", sessionID, true) + }, + } +} diff --git a/packages/app/src/pages/session/use-session-history-backfill.ts b/packages/app/src/pages/session/use-session-history-backfill.ts new file mode 100644 index 00000000..344e6c94 --- /dev/null +++ b/packages/app/src/pages/session/use-session-history-backfill.ts @@ -0,0 +1,62 @@ +import { createEffect, on, onCleanup } from "solid-js" +import type { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" + +export function createSessionHistoryBackfill(input: { + routeSessionID: () => string | undefined + sessionID: () => string | undefined + messagesReady: () => boolean + historyWindow: ReturnType + historyMore: () => boolean + historyLoading: () => boolean + visibleUserMessagesLength: () => number + userScrolled: () => boolean + scroller: () => HTMLElement | undefined +}) { + let fillFrame: number | undefined + + const fill = () => { + if (fillFrame !== undefined) return + + fillFrame = requestAnimationFrame(() => { + fillFrame = undefined + + if (!input.sessionID() || !input.messagesReady()) return + if (input.userScrolled() || input.historyLoading()) return + + const el = input.scroller() + if (!el) return + if (el.scrollHeight > el.clientHeight + 1) return + if (input.historyWindow.turnStart() <= 0 && !input.historyMore()) return + + void input.historyWindow.loadAndReveal() + }) + } + + createEffect( + on( + () => + [ + input.routeSessionID(), + input.sessionID(), + input.messagesReady(), + input.historyWindow.turnStart(), + input.historyMore(), + input.historyLoading(), + input.userScrolled(), + input.visibleUserMessagesLength(), + ] as const, + ([, id, ready, start, more, loading, scrolled]) => { + if (!id || !ready || loading || scrolled) return + if (start <= 0 && !more) return + fill() + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) + }) + + return { fill } +} diff --git a/packages/app/src/pages/session/use-session-history-window.test.ts b/packages/app/src/pages/session/use-session-history-window.test.ts new file mode 100644 index 00000000..88bc5e7c --- /dev/null +++ b/packages/app/src/pages/session/use-session-history-window.test.ts @@ -0,0 +1,59 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createSessionHistoryWindow } from "./use-session-history-window" + +const userMessage = (id: number) => + ({ + id: `msg_${id}`, + role: "user", + time: { created: Date.now() }, + }) as UserMessage + +describe("session history window extraction", () => { + test("renders only the last ten messages for long sessions", () => { + createRoot((dispose) => { + const messages = Array.from({ length: 18 }, (_, index) => userMessage(index)) + const history = createSessionHistoryWindow({ + sessionID: () => "ses_1", + messagesReady: () => true, + loaded: () => messages.length, + visibleUserMessages: () => messages, + historyMore: () => false, + historyLoading: () => false, + loadMore: async () => undefined, + userScrolled: () => false, + scroller: () => undefined, + }) + + expect(history.turnStart()).toBe(8) + expect(history.renderedUserMessages().map((message) => message.id)).toEqual( + messages.slice(8).map((message) => message.id), + ) + dispose() + }) + }) + + test("renders all messages for short sessions", () => { + createRoot((dispose) => { + const messages = Array.from({ length: 7 }, (_, index) => userMessage(index)) + const history = createSessionHistoryWindow({ + sessionID: () => "ses_1", + messagesReady: () => true, + loaded: () => messages.length, + visibleUserMessages: () => messages, + historyMore: () => false, + historyLoading: () => false, + loadMore: async () => undefined, + userScrolled: () => false, + scroller: () => undefined, + }) + + expect(history.turnStart()).toBe(0) + expect(history.renderedUserMessages().map((message) => message.id)).toEqual( + messages.map((message) => message.id), + ) + dispose() + }) + }) +}) diff --git a/packages/app/src/pages/session/use-session-history-window.ts b/packages/app/src/pages/session/use-session-history-window.ts new file mode 100644 index 00000000..bf259168 --- /dev/null +++ b/packages/app/src/pages/session/use-session-history-window.ts @@ -0,0 +1,250 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, on } from "solid-js" +import { createStore } from "solid-js/store" +import { emptyUserMessages } from "@/pages/session/session-messages" +import { same } from "@/utils/same" + +export 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. + */ +export 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, + } +} diff --git a/packages/app/src/pages/session/use-session-keyboard-focus.ts b/packages/app/src/pages/session/use-session-keyboard-focus.ts new file mode 100644 index 00000000..4695ed08 --- /dev/null +++ b/packages/app/src/pages/session/use-session-keyboard-focus.ts @@ -0,0 +1,71 @@ +import { makeEventListener } from "@solid-primitives/event-listener" +import { onMount } from "solid-js" +import { focusTerminalById, shouldFocusTerminalOnKeyDown } from "@/pages/session/helpers" + +export function useSessionKeyboardFocus(input: { + blocked: () => boolean + dialogActive: () => boolean + inputRef: () => HTMLDivElement | undefined + isChildSession: () => boolean + markScrollGesture: (target?: EventTarget | null) => void + terminalActive: () => string | undefined + terminalOpened: () => boolean +}) { + 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 (input.dialogActive()) return + + const composer = input.inputRef() + if (activeElement === composer) { + if (event.key === "Escape") composer?.blur() + return + } + + // Prefer the open terminal over the composer when it can take focus. + if (input.terminalOpened()) { + const id = input.terminalActive() + 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") { + input.markScrollGesture() + return + } + + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { + if (input.blocked() || input.isChildSession()) return + composer?.focus() + } + } + + onMount(() => { + makeEventListener(document, "keydown", handleKeyDown) + }) +} diff --git a/packages/app/src/pages/session/use-session-new-worktree.ts b/packages/app/src/pages/session/use-session-new-worktree.ts new file mode 100644 index 00000000..42bc919c --- /dev/null +++ b/packages/app/src/pages/session/use-session-new-worktree.ts @@ -0,0 +1,30 @@ +import { createEffect, createMemo, createSignal, on } from "solid-js" + +export function createSessionNewWorktree(input: { + directory: () => string + projectWorktree: () => string | undefined +}) { + const [value, setValue] = createSignal("main") + + const selected = createMemo(() => { + if (value() === "create") return "create" + const worktree = input.projectWorktree() + if (worktree && input.directory() !== worktree) return input.directory() + return "main" + }) + + const reset = () => setValue("main") + + createEffect( + on( + input.directory, + (dir) => { + if (!dir) return + reset() + }, + { defer: true }, + ), + ) + + return { selected, reset } +} diff --git a/packages/app/src/pages/session/use-session-refresh-effects.ts b/packages/app/src/pages/session/use-session-refresh-effects.ts new file mode 100644 index 00000000..42384155 --- /dev/null +++ b/packages/app/src/pages/session/use-session-refresh-effects.ts @@ -0,0 +1,89 @@ +import { createEffect, on, onCleanup, untrack } from "solid-js" +import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" + +export function useSessionRefreshEffects(input: { + directory: () => string + routeSessionID: () => string | undefined + timelineSessionID: () => string | undefined + statusType: (sessionID: string) => string | undefined + blocked: () => boolean + hasMessageCache: (sessionID: string) => boolean + hasTodoCache: (sessionID: string) => boolean + syncSession: (sessionID: string, options?: { force?: boolean }) => void | Promise + syncTodo: (sessionID: string, options?: { force?: boolean }) => void | Promise +}) { + let refreshFrame: number | undefined + let refreshTimer: number | undefined + let todoFrame: number | undefined + let todoTimer: number | undefined + + createEffect( + on([input.directory, input.routeSessionID] as const, ([, id]) => { + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + refreshFrame = undefined + refreshTimer = undefined + if (!id) return + + const cached = untrack(() => input.hasMessageCache(id)) + const stale = !cached + ? false + : (() => { + const info = getSessionPrefetch(input.directory(), id) + if (!info) return true + return Date.now() - info.at > SESSION_PREFETCH_TTL + })() + untrack(() => { + void input.syncSession(id) + }) + + refreshFrame = requestAnimationFrame(() => { + refreshFrame = undefined + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + if (input.routeSessionID() !== id) return + untrack(() => { + if (stale) void input.syncSession(id, { force: true }) + }) + }, 0) + }) + }), + ) + + createEffect( + on( + () => { + const id = input.timelineSessionID() + return [input.directory(), id, id ? (input.statusType(id) ?? "idle") : "idle", id ? input.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(() => input.hasTodoCache(id)) + + todoFrame = requestAnimationFrame(() => { + todoFrame = undefined + todoTimer = window.setTimeout(() => { + todoTimer = undefined + if (input.directory() !== dir || input.timelineSessionID() !== id) return + untrack(() => { + void input.syncTodo(id, cached ? { force: true } : undefined) + }) + }, 0) + }) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) + if (todoTimer !== undefined) window.clearTimeout(todoTimer) + }) +} diff --git a/packages/app/src/pages/session/use-session-revert.test.ts b/packages/app/src/pages/session/use-session-revert.test.ts new file mode 100644 index 00000000..3baa6ad1 --- /dev/null +++ b/packages/app/src/pages/session/use-session-revert.test.ts @@ -0,0 +1,28 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { describe, expect, test } from "bun:test" +import { nextRestoreTarget, rolledRevertItems } from "./use-session-revert" + +const message = (id: string) => ({ id, role: "user" }) as UserMessage + +describe("session revert", () => { + test("builds rolled items from the revert message onward using existing line text", () => { + expect( + rolledRevertItems({ + revertMessageID: "msg_2", + messages: [message("msg_10"), message("msg_2"), message("msg_30")], + lineText: (id) => `line:${id}`, + }), + ).toEqual([ + { id: "msg_2", text: "line:msg_2" }, + { id: "msg_30", text: "line:msg_30" }, + ]) + }) + + test("selects the next restore target by timeline position instead of id order", () => { + const messages = [message("msg_10"), message("msg_2"), message("msg_30")] + + expect(nextRestoreTarget(messages, "msg_10")?.id).toBe("msg_2") + expect(nextRestoreTarget(messages, "msg_30")).toBeUndefined() + expect(nextRestoreTarget(messages, "missing")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/use-session-revert.ts b/packages/app/src/pages/session/use-session-revert.ts new file mode 100644 index 00000000..34b2d730 --- /dev/null +++ b/packages/app/src/pages/session/use-session-revert.ts @@ -0,0 +1,140 @@ +import type { Session, UserMessage } from "@opencode-ai/sdk/v2" +import { useMutation } from "@tanstack/solid-query" +import { batch, createMemo } from "solid-js" +import type { Prompt, usePrompt } from "@/context/prompt" +import type { useSDK } from "@/context/sdk" +import type { useSync } from "@/context/sync" +import { readSessionMessages, readUserMessages } from "@/pages/session/session-messages" + +export function rolledRevertItems(input: { + revertMessageID: string | undefined + messages: UserMessage[] + lineText: (id: string) => string +}) { + const id = input.revertMessageID + if (!id) return [] + const start = input.messages.findIndex((item) => item.id === id) + if (start < 0) return [] + return input.messages + .slice(start) + .map((item) => ({ id: item.id, text: input.lineText(item.id) })) +} + +export function nextRestoreTarget(messages: UserMessage[], id: string) { + const index = messages.findIndex((item) => item.id === id) + return index >= 0 ? messages[index + 1] : undefined +} + +export function createSessionRevert(input: { + sessionID: () => string | undefined + revertMessageID: () => string | undefined + timelineUserMessages: () => UserMessage[] + lineText: (id: string) => string + prompt: ReturnType + sync: ReturnType + client: ReturnType["client"] + halt: (sessionID: string) => Promise + draft: (id: string) => Prompt + fail: (err: unknown) => void + merge: (next: Session) => void + roll: (sessionID: string, next: Session["revert"]) => void +}) { + const revertMutation = useMutation(() => ({ + mutationFn: async (request: { sessionID: string; messageID: string }) => { + const prev = input.prompt.current().slice() + const last = input.sync.session.get(request.sessionID)?.revert + const value = input.draft(request.messageID) + batch(() => { + input.roll(request.sessionID, { messageID: request.messageID }) + input.prompt.set(value) + }) + await input + .halt(request.sessionID) + .then(() => input.client.session.revert(request, { throwOnError: true })) + .then((result) => { + if (result.data) input.merge(result.data) + }) + .catch((err) => { + batch(() => { + input.roll(request.sessionID, last) + input.prompt.set(prev) + }) + input.fail(err) + }) + }, + })) + + const restoreMutation = useMutation(() => ({ + mutationFn: async (request: { sessionID: string; id: string }) => { + const messages = readUserMessages(readSessionMessages(input.sync.data.message[request.sessionID])) + const next = nextRestoreTarget(messages, request.id) + const prev = input.prompt.current().slice() + const last = input.sync.session.get(request.sessionID)?.revert + + batch(() => { + input.roll(request.sessionID, next ? { messageID: next.id } : undefined) + if (next) { + input.prompt.set(input.draft(next.id)) + } else { + input.prompt.reset() + } + }) + + const task = !next + ? input + .halt(request.sessionID) + .then(() => input.client.session.unrevert({ sessionID: request.sessionID }, { throwOnError: true })) + : input.halt(request.sessionID).then(() => + input.client.session.revert( + { + sessionID: request.sessionID, + messageID: next.id, + }, + { throwOnError: true }, + ), + ) + + await task + .then((result) => { + if (result.data) input.merge(result.data) + }) + .catch((err) => { + batch(() => { + input.roll(request.sessionID, last) + input.prompt.set(prev) + }) + input.fail(err) + }) + }, + })) + + const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const restoring = createMemo(() => { + if (!restoreMutation.isPending) return + const variables = restoreMutation.variables + if (variables?.sessionID !== input.sessionID()) return + return variables.id + }) + const rolled = createMemo(() => + rolledRevertItems({ + revertMessageID: input.revertMessageID(), + messages: input.timelineUserMessages(), + lineText: input.lineText, + }), + ) + + return { + reverting, + restoring, + rolled, + revert(request: { sessionID: string; messageID: string }) { + if (reverting()) return + return revertMutation.mutateAsync(request) + }, + restore(id: string) { + const sessionID = input.sessionID() + if (!sessionID || reverting()) return + return restoreMutation.mutateAsync({ sessionID, id }) + }, + } +} diff --git a/packages/app/src/pages/session/use-session-review-panel.tsx b/packages/app/src/pages/session/use-session-review-panel.tsx new file mode 100644 index 00000000..1059bf3c --- /dev/null +++ b/packages/app/src/pages/session/use-session-review-panel.tsx @@ -0,0 +1,198 @@ +import { createEffect, createMemo, on, onCleanup, untrack } from "solid-js" +import type { useComments } from "@/context/comments" +import type { useFile } from "@/context/file" +import type { useLanguage } from "@/context/language" +import type { useSDK } from "@/context/sdk" +import type { useSync } from "@/context/sync" +import { nextFilesPanelAutoOpen } from "@/pages/session/files-tab-state" +import { createOpenReviewFile } from "@/pages/session/helpers" +import { createReviewPanelScroll } from "@/pages/session/review-panel-scroll" +import { createReviewPanelView } from "@/pages/session/review-panel-view" +import type { useSessionLayout } from "@/pages/session/session-layout" +import type { createSessionCommentContext } from "@/pages/session/use-session-comment-context" +import type { createSessionReviewState } from "@/pages/session/use-session-review-state" + +export function createSessionReviewPanel(input: { + activeFileTab: () => string | undefined + canReview: () => boolean + comments: ReturnType + commentContext: ReturnType + deferRender: () => boolean + file: ReturnType + isDesktop: () => boolean + language: ReturnType + reviewState: ReturnType + routeSessionID: () => string | undefined + sdk: ReturnType + sessionKey: () => string + sync: ReturnType + timelineDiffs: () => Array<{ status?: string | null }> + turnDiffs: () => Array<{ status?: string | null }> + view: ReturnType["view"] + wantsReview: () => boolean + openTab: (tab: string) => void + setActiveTab: (tab: string) => void +}) { + let diffFrame: number | undefined + let diffTimer: number | undefined + + createEffect(() => { + if (!input.routeSessionID()) return + + const source = input.timelineDiffs().length > 0 ? input.timelineDiffs() : input.turnDiffs() + const next = nextFilesPanelAutoOpen( + { + seenAdded: input.view().sidePanel.filesAutoOpenSeen(), + dismissed: input.view().sidePanel.filesAutoOpenDismissed(), + }, + source, + ) + + if (next.open) { + input.view().sidePanel.setTab("files") + input.view().sidePanel.open() + } + input.view().sidePanel.setAutoOpenState(next) + }) + + createEffect( + on( + () => input.sync.data.session_status[input.routeSessionID() ?? ""]?.type, + (next, prev) => { + const mode = input.reviewState.vcsMode() + if (!mode) return + if (!input.wantsReview()) return + if (next !== "idle" || prev === undefined || prev === "idle") return + void input.reviewState.loadVcs(mode, true) + }, + { defer: true }, + ), + ) + + const fileTreeTab = () => input.view().sidePanel.explorer.tab() + const setFileTreeTab = (value: "changes" | "all") => input.view().sidePanel.explorer.setTab(value) + + const showAllFiles = () => { + if (fileTreeTab() !== "changes") return + setFileTreeTab("all") + } + + const openReviewFile = createOpenReviewFile({ + showAllFiles, + tabForPath: input.file.tab, + openTab: input.openTab, + setActive: input.setActiveTab, + loadFile: input.file.load, + }) + + const activeReviewPath = createMemo(() => { + if (!input.wantsReview()) return + const tab = input.activeFileTab() + if (!tab) return + const path = input.file.pathFromTab(tab) + if (!path) return + return input.reviewState.reviewDiffs().some((diff) => diff.file === path) ? path : undefined + }) + + const scroll = createReviewPanelScroll({ + activeReviewPath, + reviewReady: input.reviewState.reviewReady, + sessionKey: input.sessionKey, + view: input.view, + }) + + const panel = createReviewPanelView({ + canReview: input.canReview, + comments: input.comments, + commentContext: input.commentContext, + deferRender: input.deferRender, + file: input.file, + focusedFile: scroll.activeDiff, + language: input.language, + onScrollRef: scroll.setReviewScroll, + onViewFile: openReviewFile, + reviewState: input.reviewState, + view: input.view, + }) + + createEffect(() => { + const id = input.routeSessionID() + if (!id) return + + if (!input.wantsReview()) return + if (input.sync.data.session_diff[id] !== undefined) return + if (input.sync.status === "loading") return + + void input.sync.session.diff(id) + }) + + createEffect( + on( + () => [input.sessionKey(), input.wantsReview()] as const, + ([key, wants]) => { + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) + diffFrame = undefined + diffTimer = undefined + if (!wants) return + + const id = input.routeSessionID() + if (!id) return + if (!untrack(() => input.sync.data.session_diff[id] !== undefined)) return + + diffFrame = requestAnimationFrame(() => { + diffFrame = undefined + diffTimer = window.setTimeout(() => { + diffTimer = undefined + if (input.sessionKey() !== key) return + void input.sync.session.diff(id, { force: true }) + }, 0) + }) + }, + { defer: true }, + ), + ) + + let treeDir: string | undefined + createEffect(() => { + const dir = input.sdk.directory + if (!input.isDesktop()) return + if (!input.view().sidePanel.opened()) return + if (input.view().sidePanel.tab() !== "review") return + if (input.sync.status === "loading") return + + fileTreeTab() + const refresh = treeDir !== dir + treeDir = dir + void (refresh ? input.file.tree.refresh("") : input.file.tree.list("")) + }) + + createEffect( + on( + () => input.sdk.directory, + () => { + const tab = input.activeFileTab() + if (!tab) return + const path = input.file.pathFromTab(tab) + if (!path) return + void input.file.load(path, { force: true }) + }, + { defer: true }, + ), + ) + + onCleanup(() => { + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) + }) + + return { + reviewContent: panel.reviewContent, + reviewPanel: panel.reviewPanel, + mobileFallback: panel.mobileFallback, + files: input.reviewState.artifactFiles, + diffs: input.reviewState.reviewDiffs, + hasReview: input.reviewState.hasReview, + reviewCount: input.reviewState.reviewCount, + } +} diff --git a/packages/app/src/pages/session/use-session-review-state.test.ts b/packages/app/src/pages/session/use-session-review-state.test.ts new file mode 100644 index 00000000..7035d434 --- /dev/null +++ b/packages/app/src/pages/session/use-session-review-state.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { deriveReviewArtifactFiles } from "./use-session-review-state" + +describe("session review state", () => { + test("uses session artifact history when it matches the visible session", () => { + const files = deriveReviewArtifactFiles({ + directory: "/repo", + sessionID: "ses_1", + history: { + sessionID: "ses_1", + artifacts: [{ file: "report.md", kind: "added" }], + }, + turnDiffs: [{ file: "fallback.md", status: "added" }], + }) + + expect(files.map((file) => file.path)).toContain("/repo/report.md") + expect(files.map((file) => file.path)).not.toContain("/repo/fallback.md") + }) + + test("falls back to added and modified turn diffs", () => { + const files = deriveReviewArtifactFiles({ + directory: "/repo", + sessionID: "ses_1", + history: { sessionID: "ses_2", artifacts: [{ file: "stale.md", kind: "added" }] }, + turnDiffs: [ + { file: "created.md", status: "added" }, + { file: "updated.md", status: "modified" }, + { file: "deleted.md", status: "deleted" }, + ], + }) + + expect(files.map((file) => file.path)).toEqual(["/repo/created.md", "/repo/updated.md"]) + }) +}) diff --git a/packages/app/src/pages/session/use-session-review-state.ts b/packages/app/src/pages/session/use-session-review-state.ts new file mode 100644 index 00000000..b4b9562d --- /dev/null +++ b/packages/app/src/pages/session/use-session-review-state.ts @@ -0,0 +1,228 @@ +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createResource, createSignal, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { useSDK } from "@/context/sdk" +import type { useSync } from "@/context/sync" +import { deriveArtifactFiles, type SessionArtifactFile } from "@/pages/session/files-tab-state" +import { + coerceReviewChangeMode, + isVcsReviewMode, + nextReviewModeForSessionChange, + reviewChangeOptions, + reviewDiffsForMode, + type ReviewChangeMode, + type VcsReviewMode, +} from "@/pages/session/review-change-mode" +import { diffs as list } from "@/utils/diffs" + +type SessionReviewDiff = SnapshotFileDiff | VcsFileDiff + +export function deriveReviewArtifactFiles(input: { + directory: string + sessionID: string | undefined + history: { sessionID: string; artifacts: SessionArtifactFile[] } | undefined + turnDiffs: Array<{ file: string; status?: string }> +}) { + const history = input.history + if (history && history.sessionID === input.sessionID && history.artifacts.length > 0) { + return deriveArtifactFiles(input.directory, history.artifacts) + } + + return deriveArtifactFiles( + input.directory, + input.turnDiffs.flatMap((diff) => { + if (diff.status !== "added" && diff.status !== "modified") return [] + return [{ file: diff.file, kind: diff.status as "added" | "modified" }] + }), + ) +} + +export function createSessionReviewState(input: { + directory: string + sessionKey: () => string + sessionID: () => string | undefined + sync: ReturnType + sdk: ReturnType + wantsReview: () => boolean + turnDiffs: () => SessionReviewDiff[] +}) { + const [changes, setChanges] = createSignal("turn") + const [vcs, setVcs] = createStore<{ + diff: Record + ready: Record + }>({ + diff: { + unstaged: [], + staged: [], + branch: [], + }, + ready: { + unstaged: false, + staged: false, + branch: false, + }, + }) + + const vcsTask = new Map>() + const vcsRun = new Map() + + const bumpVcs = (mode: VcsReviewMode) => { + const next = (vcsRun.get(mode) ?? 0) + 1 + vcsRun.set(mode, next) + return next + } + + const resetVcs = (mode?: VcsReviewMode) => { + const modes = mode ? [mode] : (["unstaged", "staged", "branch"] as const) + modes.forEach((item) => { + bumpVcs(item) + vcsTask.delete(item) + setVcs("diff", item, []) + setVcs("ready", item, false) + }) + } + + const loadVcs = (mode: VcsReviewMode, force = false) => { + if (input.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 = input.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: unknown) => { + 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 changesOptions = createMemo(() => + reviewChangeOptions({ isGit: input.sync.project?.vcs === "git" }), + ) + const vcsMode = createMemo(() => { + const value = changes() + if (isVcsReviewMode(value)) return value + }) + const reviewDiffs = createMemo(() => + list( + reviewDiffsForMode(changes(), { + turn: input.turnDiffs(), + vcs: vcs.diff, + }), + ), + ) + const reviewCount = createMemo(() => reviewDiffs().length) + const hasReview = createMemo(() => reviewCount() > 0) + const reviewReady = createMemo(() => { + const value = changes() + return isVcsReviewMode(value) ? vcs.ready[value] : true + }) + + const [artifactHistory, { refetch: refetchArtifactHistory }] = createResource( + input.sessionID, + async (sessionID) => ({ + sessionID, + artifacts: await input.sdk.client.session + .artifacts({ sessionID }) + .then((res) => res.data ?? []) + .catch(() => []), + }), + { initialValue: { sessionID: "", artifacts: [] as SessionArtifactFile[] } }, + ) + let artifactHistoryFrame: number | undefined + let artifactHistoryPending = false + const queueArtifactHistoryRefetch = () => { + artifactHistoryPending = true + if (artifactHistoryFrame !== undefined) return + artifactHistoryFrame = requestAnimationFrame(() => { + artifactHistoryFrame = undefined + if (!artifactHistoryPending) return + artifactHistoryPending = false + void refetchArtifactHistory() + }) + } + onCleanup(() => { + if (artifactHistoryFrame !== undefined) cancelAnimationFrame(artifactHistoryFrame) + }) + const artifactFiles = createMemo(() => + deriveReviewArtifactFiles({ + directory: input.directory, + sessionID: input.sessionID(), + history: artifactHistory.latest, + turnDiffs: input.turnDiffs(), + }), + ) + + createEffect( + on( + input.sessionKey, + () => { + setChanges(nextReviewModeForSessionChange()) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const options = changesOptions() + const current = changes() + const next = coerceReviewChangeMode(current, options) + if (next !== current) setChanges(next) + }) + + createEffect(() => { + const mode = vcsMode() + if (!mode) return + if (!input.wantsReview()) return + void loadVcs(mode) + }) + + createEffect(() => { + const id = input.sessionID() + if (!id) return + input.turnDiffs() + queueArtifactHistoryRefetch() + }) + + createEffect(() => { + const id = input.sessionID() + if (!id) return + if (input.sync.data.session_diff[id] === undefined) return + queueArtifactHistoryRefetch() + }) + + return { + changes, + setChanges, + changesOptions, + vcsMode, + reviewDiffs, + reviewCount, + hasReview, + reviewReady, + artifactFiles, + resetVcs, + loadVcs, + } +} diff --git a/packages/app/src/pages/session/use-session-route-tabs.ts b/packages/app/src/pages/session/use-session-route-tabs.ts new file mode 100644 index 00000000..1d04c220 --- /dev/null +++ b/packages/app/src/pages/session/use-session-route-tabs.ts @@ -0,0 +1,79 @@ +import { createEffect, createMemo, on } from "solid-js" +import type { useLayout } from "@/context/layout" +import { createSessionTabs } from "@/pages/session/helpers" +import type { useSessionLayout } from "@/pages/session/session-layout" + +export function createSessionRouteTabs(input: { + directory: () => string + sessionID: () => string | undefined + layout: ReturnType + tabs: ReturnType["tabs"] + pathFromTab: (tab: string) => string | undefined + tabForPath: (path: string) => string + review: () => boolean + hasReview: () => boolean +}) { + const workspaceKey = createMemo(() => input.directory()) + const workspaceTabs = createMemo(() => input.layout.tabs(workspaceKey())) + + function normalizeTab(tab: string) { + if (!tab.startsWith("file://")) return tab + return input.tabForPath(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 + } + + createEffect( + on( + input.sessionID, + (id, prev) => { + if (!id) return + if (prev) return + + const pending = input.layout.handoff.tabs() + if (!pending) return + if (Date.now() - pending.at > 60_000) { + input.layout.handoff.clearTabs() + return + } + + if (pending.id !== id) return + input.layout.handoff.clearTabs() + if (pending.dir !== input.directory()) return + + const from = workspaceTabs().tabs() + if (from.all.length === 0 && !from.active) return + + const current = input.tabs().tabs() + if (current.all.length > 0 || current.active) return + + const all = normalizeTabs(from.all) + const active = from.active ? normalizeTab(from.active) : undefined + input.tabs().setAll(all) + input.tabs().setActive(active && all.includes(active) ? active : all[0]) + + workspaceTabs().setAll([]) + workspaceTabs().setActive(undefined) + }, + { defer: true }, + ), + ) + + return createSessionTabs({ + tabs: input.tabs, + pathFromTab: input.pathFromTab, + normalizeTab, + review: input.review, + hasReview: input.hasReview, + }) +} diff --git a/packages/app/src/pages/session/use-session-scroll-dock.test.ts b/packages/app/src/pages/session/use-session-scroll-dock.test.ts new file mode 100644 index 00000000..7281373e --- /dev/null +++ b/packages/app/src/pages/session/use-session-scroll-dock.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test" +import { + calculateSessionScrollState, + shouldStickToBottomAfterDockResize, + syncComposerDockHeight, +} from "./use-session-scroll-dock" + +function makeScroller(input: { + clientHeight: number + scrollHeight: number + scrollTop: number +}) { + const el = document.createElement("div") as HTMLDivElement + let top = input.scrollTop + let height = input.scrollHeight + + Object.defineProperties(el, { + clientHeight: { value: input.clientHeight, configurable: true }, + scrollHeight: { + configurable: true, + get: () => height, + set: (value) => { + height = value + }, + }, + scrollTop: { + configurable: true, + get: () => top, + set: (value) => { + top = value + }, + }, + }) + + return { + el, + get top() { + return top + }, + setScrollHeight(value: number) { + height = value + }, + } +} + +describe("session scroll dock", () => { + test("calculates bottom state with two-pixel tolerance", () => { + const state = calculateSessionScrollState({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 599, + }) + + expect(state).toEqual({ + overflow: true, + bottom: true, + jump: false, + }) + }) + + test("marks jump when distance is larger than viewport threshold", () => { + const state = calculateSessionScrollState({ + clientHeight: 400, + scrollHeight: 1400, + scrollTop: 100, + }) + + expect(state).toEqual({ + overflow: true, + bottom: false, + jump: true, + }) + }) + + test("sticks to bottom when the user is already following the latest turn", () => { + const scroller = makeScroller({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 600, + }) + + const stick = shouldStickToBottomAfterDockResize({ + el: scroller.el, + userScrolled: false, + previousDockHeight: 120, + nextDockHeight: 180, + }) + + expect(stick).toBe(true) + }) + + test("does not force bottom when the user intentionally scrolled upward", () => { + const scroller = makeScroller({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 200, + }) + + const stick = shouldStickToBottomAfterDockResize({ + el: scroller.el, + userScrolled: true, + previousDockHeight: 120, + nextDockHeight: 180, + }) + + expect(stick).toBe(false) + }) + + test("syncs composer height through one path and scrolls once when sticky", () => { + const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height") + const scroller = makeScroller({ + clientHeight: 400, + scrollHeight: 1000, + scrollTop: 600, + }) + const calls: number[] = [] + + try { + const next = syncComposerDockHeight({ + el: scroller.el, + previousDockHeight: 120, + nextDockHeight: 180, + userScrolled: false, + setCssHeight: (height) => { + document.documentElement.style.setProperty("--composer-dock-height", `${height}px`) + }, + forceScrollToBottom: () => { + calls.push(1) + scroller.el.scrollTop = scroller.el.scrollHeight + }, + scheduleScrollState: () => undefined, + fill: () => undefined, + }) + + expect(next).toBe(180) + expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("180px") + expect(calls).toHaveLength(1) + expect(scroller.top).toBe(1000) + } finally { + if (previousDockHeight) document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight) + else document.documentElement.style.removeProperty("--composer-dock-height") + } + }) +}) diff --git a/packages/app/src/pages/session/use-session-scroll-dock.ts b/packages/app/src/pages/session/use-session-scroll-dock.ts new file mode 100644 index 00000000..7dd501d5 --- /dev/null +++ b/packages/app/src/pages/session/use-session-scroll-dock.ts @@ -0,0 +1,204 @@ +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createAutoScroll } from "@opencode-ai/ui/hooks" +import { createEffect, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" + +export type SessionScrollState = { + overflow: boolean + bottom: boolean + jump: boolean +} + +export function calculateSessionScrollState(input: { + clientHeight: number + scrollHeight: number + scrollTop: number +}): SessionScrollState { + const max = input.scrollHeight - input.clientHeight + const distance = max - input.scrollTop + const overflow = max > 1 + const jumpThreshold = Math.max(400, input.clientHeight) + + return { + overflow, + bottom: !overflow || distance <= 2, + jump: overflow && distance > jumpThreshold, + } +} + +export function shouldStickToBottomAfterDockResize(input: { + el: HTMLElement + userScrolled: boolean + previousDockHeight: number + nextDockHeight: number +}) { + const delta = input.nextDockHeight - input.previousDockHeight + const distance = input.el.scrollHeight - input.el.clientHeight - input.el.scrollTop + return !input.userScrolled || distance < 10 + Math.max(0, delta) +} + +export function syncComposerDockHeight(input: { + el: HTMLElement | undefined + previousDockHeight: number + nextDockHeight: number + userScrolled: boolean + setCssHeight: (height: number) => void + forceScrollToBottom: () => void + scheduleScrollState: (el: HTMLDivElement) => void + fill: () => void +}) { + input.setCssHeight(input.nextDockHeight) + + if (input.nextDockHeight === input.previousDockHeight) { + if (input.el instanceof HTMLDivElement) input.scheduleScrollState(input.el) + input.fill() + return input.previousDockHeight + } + + const stick = input.el + ? shouldStickToBottomAfterDockResize({ + el: input.el, + userScrolled: input.userScrolled, + previousDockHeight: input.previousDockHeight, + nextDockHeight: input.nextDockHeight, + }) + : false + + if (stick) input.forceScrollToBottom() + if (input.el instanceof HTMLDivElement) input.scheduleScrollState(input.el) + input.fill() + + return input.nextDockHeight +} + +export function createSessionScrollDock(input: { + clearMessageHash: () => void + clearActiveMessage: () => void + fill: () => void +}) { + const autoScroll = createAutoScroll({ + working: () => true, + overflowAnchor: "dynamic", + }) + + const [scroll, setScroll] = createStore({ + overflow: false, + bottom: true, + jump: false, + }) + + let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + let promptDock: HTMLDivElement | undefined + let dockHeight = 0 + let scrollStateFrame: number | undefined + let scrollStateTarget: HTMLDivElement | undefined + + const updateScrollState = (el: HTMLDivElement) => { + const next = calculateSessionScrollState({ + clientHeight: el.clientHeight, + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop, + }) + + if (scroll.overflow === next.overflow && scroll.bottom === next.bottom && scroll.jump === next.jump) return + setScroll(next) + } + + const scheduleScrollState = (el: HTMLDivElement) => { + scrollStateTarget = el + if (scrollStateFrame !== undefined) return + + scrollStateFrame = requestAnimationFrame(() => { + scrollStateFrame = undefined + + const target = scrollStateTarget + scrollStateTarget = undefined + if (target) updateScrollState(target) + }) + } + + const setScrollRef = (el: HTMLDivElement | undefined) => { + scroller = el + autoScroll.scrollRef(el) + if (!el) return + scheduleScrollState(el) + input.fill() + } + + const setContentRef = (el: HTMLDivElement | undefined) => { + content = el + autoScroll.contentRef(el) + if (el && scroller) scheduleScrollState(scroller) + } + + const updateDockHeight = (next: number) => { + dockHeight = syncComposerDockHeight({ + el: scroller, + previousDockHeight: dockHeight, + nextDockHeight: next, + userScrolled: autoScroll.userScrolled(), + setCssHeight: (value) => document.documentElement.style.setProperty("--composer-dock-height", `${value}px`), + forceScrollToBottom: autoScroll.forceScrollToBottom, + scheduleScrollState, + fill: input.fill, + }) + } + + const setPromptDockRef = (el: HTMLDivElement | undefined) => { + promptDock = el + if (!el) return + const next = Math.ceil(el.getBoundingClientRect().height) + if (next > 0) updateDockHeight(next) + } + + const resumeScroll = () => { + input.clearActiveMessage() + autoScroll.forceScrollToBottom() + input.clearMessageHash() + if (scroller) scheduleScrollState(scroller) + } + + createEffect( + on( + autoScroll.userScrolled, + (scrolled) => { + if (scrolled) return + input.clearActiveMessage() + input.clearMessageHash() + }, + { defer: true }, + ), + ) + + createResizeObserver( + () => content, + () => { + if (scroller) scheduleScrollState(scroller) + input.fill() + }, + ) + + createResizeObserver( + () => promptDock, + ({ height }) => { + updateDockHeight(Math.ceil(height)) + }, + ) + + onCleanup(() => { + if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) + document.documentElement.style.removeProperty("--composer-dock-height") + }) + + return { + autoScroll, + scroll, + scroller: () => scroller, + setScrollRef, + setContentRef, + setPromptDockRef, + scheduleScrollState, + resumeScroll, + } +} diff --git a/packages/app/src/pages/session/use-session-timeline-data.ts b/packages/app/src/pages/session/use-session-timeline-data.ts new file mode 100644 index 00000000..5b5e373f --- /dev/null +++ b/packages/app/src/pages/session/use-session-timeline-data.ts @@ -0,0 +1,147 @@ +import { createEffect, createMemo, on } from "solid-js" +import type { useLocal } from "@/context/local" +import type { useSync } from "@/context/sync" +import { createSessionViewController } from "@/pages/session/session-view-controller" +import { + emptyMessages, + emptyUserMessages, + readSessionMessages, + readUserMessages, +} from "@/pages/session/session-messages" +import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { diffs as list } from "@/utils/diffs" +import { same } from "@/utils/same" + +export function createSessionTimelineData(input: { + directory: () => string + routeSessionID: () => string | undefined + sync: ReturnType + local: ReturnType +}) { + const routeInfo = createMemo(() => { + const id = input.routeSessionID() + return id ? input.sync.session.get(id) : undefined + }) + const routeDiffs = createMemo(() => { + const id = input.routeSessionID() + return id ? list(input.sync.data.session_diff[id]) : [] + }) + const routeSessionCount = createMemo(() => Math.max(routeInfo()?.summary?.files ?? 0, routeDiffs().length)) + const routeHasSessionReview = createMemo(() => routeSessionCount() > 0) + const routeMessagesReady = createMemo(() => { + const id = input.routeSessionID() + if (!id) return true + return input.sync.data.message[id] !== undefined + }) + + const sessionView = createSessionViewController({ + directory: input.directory, + routeSessionID: input.routeSessionID, + routeMessagesReady, + }) + const sessionID = sessionView.visible.id + const sessionKey = sessionView.visible.key + const sessionInfo = createMemo(() => { + const id = sessionID() + if (!id) return + return input.sync.session.get(id) + }) + const isChildSession = createMemo(() => !!sessionInfo()?.parentID) + const messages = createMemo( + () => { + const id = sessionID() + return readSessionMessages(id ? input.sync.data.message[id] : undefined) + }, + emptyMessages, + { equals: same }, + ) + const messagesReady = sessionView.visible.ready + const diffs = createMemo(() => { + const id = sessionID() + if (!id) return [] + return list(input.sync.data.session_diff[id]) + }) + const userMessages = createMemo(() => readUserMessages(messages()), emptyUserMessages, { + equals: same, + }) + const revertMessageID = createMemo(() => { + const id = sessionID() + if (!id) return + return input.sync.session.get(id)?.revert?.messageID + }) + const visibleUserMessages = createMemo( + () => { + const revert = revertMessageID() + if (!revert) return userMessages() + return userMessages().filter((m) => m.id < revert) + }, + emptyUserMessages, + { equals: same }, + ) + const historyMore = createMemo(() => { + const id = sessionID() + if (!id) return false + return input.sync.session.history.more(id) + }) + const historyLoading = createMemo(() => { + const id = sessionID() + if (!id) return false + return input.sync.session.history.loading(id) + }) + const routeHistoryMore = createMemo(() => { + const id = input.routeSessionID() + if (!id) return false + return input.sync.session.history.more(id) + }) + const routeHistoryLoading = createMemo(() => { + const id = input.routeSessionID() + if (!id) return false + return input.sync.session.history.loading(id) + }) + const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) + + createEffect( + on( + () => lastUserMessage()?.id, + () => { + const msg = lastUserMessage() + if (!msg) return + syncSessionModel(input.local, msg) + }, + ), + ) + + createEffect( + on( + () => ({ dir: input.directory(), id: input.routeSessionID() }), + (next, prev) => { + if (!prev) return + if (next.dir === prev.dir && next.id === prev.id) return + if (prev.id && !next.id) input.local.session.reset() + }, + { defer: true }, + ), + ) + + return { + routeInfo, + routeDiffs, + routeSessionCount, + routeHasSessionReview, + sessionID, + sessionKey, + sessionInfo, + isChildSession, + messages, + messagesReady, + diffs, + userMessages, + revertMessageID, + visibleUserMessages, + historyMore, + historyLoading, + routeHistoryMore, + routeHistoryLoading, + lastUserMessage, + } +} diff --git a/packages/app/src/pages/session/use-session-timeline-interaction.ts b/packages/app/src/pages/session/use-session-timeline-interaction.ts new file mode 100644 index 00000000..0bdfb25e --- /dev/null +++ b/packages/app/src/pages/session/use-session-timeline-interaction.ts @@ -0,0 +1,99 @@ +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { createSessionActiveMessage } from "@/pages/session/use-session-active-message" +import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { createSessionHistoryBackfill } from "@/pages/session/use-session-history-backfill" +import { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" +import { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" + +export function createSessionTimelineInteraction(input: { + routeSessionID: () => string | undefined + sessionKey: () => string + sessionID: () => string | undefined + messagesReady: () => boolean + loadedMessages: () => number + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + consumePendingMessage: (key: string) => string | undefined +}) { + const anchor = (id: string) => `message-${id}` + let clearMessageHash = () => {} + let activeMessage!: ReturnType + let historyBackfill: ReturnType | undefined + + const scrollDock = createSessionScrollDock({ + clearMessageHash: () => clearMessageHash(), + clearActiveMessage: () => activeMessage?.clearActiveMessage(), + fill: () => historyBackfill?.fill(), + }) + const autoScroll = scrollDock.autoScroll + const resumeScroll = scrollDock.resumeScroll + + activeMessage = createSessionActiveMessage({ + sessionKey: input.sessionKey, + visibleUserMessages: input.visibleUserMessages, + lastUserMessageID: () => input.visibleUserMessages().at(-1)?.id, + scroller: scrollDock.scroller, + resumeScroll, + pauseAutoScroll: autoScroll.pause, + }) + + const historyWindow = createSessionHistoryWindow({ + sessionID: input.sessionID, + messagesReady: input.messagesReady, + loaded: input.loadedMessages, + visibleUserMessages: input.visibleUserMessages, + historyMore: input.historyMore, + historyLoading: input.historyLoading, + loadMore: input.loadMore, + userScrolled: autoScroll.userScrolled, + scroller: scrollDock.scroller, + }) + + historyBackfill = createSessionHistoryBackfill({ + routeSessionID: input.routeSessionID, + sessionID: input.sessionID, + messagesReady: input.messagesReady, + historyWindow, + historyMore: input.historyMore, + historyLoading: input.historyLoading, + visibleUserMessagesLength: () => input.visibleUserMessages().length, + userScrolled: autoScroll.userScrolled, + scroller: scrollDock.scroller, + }) + + const hashScroll = useSessionHashScroll({ + sessionKey: input.sessionKey, + sessionID: input.sessionID, + messagesReady: input.messagesReady, + visibleUserMessages: input.visibleUserMessages, + historyMore: input.historyMore, + historyLoading: input.historyLoading, + loadMore: input.loadMore, + turnStart: historyWindow.turnStart, + currentMessageId: activeMessage.messageId, + pendingMessage: activeMessage.pendingMessage, + setPendingMessage: activeMessage.setPendingMessage, + setActiveMessage: activeMessage.setActiveMessage, + setTurnStart: historyWindow.setTurnStart, + autoScroll, + scroller: scrollDock.scroller, + anchor, + scheduleScrollState: scrollDock.scheduleScrollState, + consumePendingMessage: input.consumePendingMessage, + }) + clearMessageHash = hashScroll.clearMessageHash + activeMessage.setScrollToMessage(hashScroll.scrollToMessage) + + return { + activeMessage, + autoScroll, + anchor, + historyWindow, + resumeScroll, + scheduleScrollState: scrollDock.scheduleScrollState, + scrollDock, + setScrollRef: scrollDock.setScrollRef, + } +} diff --git a/packages/app/src/pages/session/use-session-vcs-refresh.ts b/packages/app/src/pages/session/use-session-vcs-refresh.ts new file mode 100644 index 00000000..2dce745b --- /dev/null +++ b/packages/app/src/pages/session/use-session-vcs-refresh.ts @@ -0,0 +1,57 @@ +import { createEffect, on, onCleanup, untrack } from "solid-js" +import type { VcsReviewMode } from "@/pages/session/review-change-mode" +import { same } from "@/utils/same" + +export function useSessionVcsRefresh(input: { + directory: () => string + event: { + listen: (handler: (event: { details: { type: string; properties?: unknown } }) => void) => () => void + } + branch: () => string | undefined + defaultBranch: () => string | undefined + reset: () => void + mode: () => VcsReviewMode | undefined + wantsReview: () => boolean + load: (mode: VcsReviewMode, force: true) => void | Promise +}) { + const refresh = () => { + input.reset() + const mode = untrack(input.mode) + if (!mode) return + if (!untrack(input.wantsReview)) return + void input.load(mode, true) + } + + createEffect( + on( + input.directory, + () => { + input.reset() + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => [input.branch(), input.defaultBranch()] as const, + (next, prev) => { + if (prev === undefined || same(next, prev)) return + refresh() + }, + { defer: true }, + ), + ) + + const stop = input.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 + refresh() + }) + onCleanup(stop) +} diff --git a/packages/app/src/shell-frame-contract.test.ts b/packages/app/src/shell-frame-contract.test.ts index 401f86ac..9fbc6a7c 100644 --- a/packages/app/src/shell-frame-contract.test.ts +++ b/packages/app/src/shell-frame-contract.test.ts @@ -36,11 +36,12 @@ test("desktop shell shares titlebar height across titlebar and narrow sidebar ge test("session composer is docked outside the scroll-clipped timeline region", () => { const session = read("./pages/session.tsx") + const sessionMainView = read("./pages/session/session-main-view.tsx") expect(session).toContain("const renderComposerRegion = (") expect(session).toContain('variant: "session" | "home"') - expect(session).toContain('
') - expect(session).toContain('
\n {renderComposerRegion("session")}') + expect(sessionMainView).toContain('
') + expect(sessionMainView).toContain("
\n {props.composerSession}") }) test("session header uses a view title on home and breadcrumb title in sessions", () => {