Download packages/app/src/pages/session.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 80.4 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/pages/session.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/pages/session.tsx
-
curl -L -o session.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/pages/session.tsx
80.4 kB
| import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" | |
| import { getFilename } from "@opencode-ai/core/util/path" | |
| import { useDialog } from "@opencode-ai/ui/context/dialog" | |
| import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" | |
| import { | |
| batch, | |
| ErrorBoundary, | |
| onCleanup, | |
| Suspense, | |
| Show, | |
| Match, | |
| Switch, | |
| createMemo, | |
| createEffect, | |
| createComputed, | |
| createSignal, | |
| on, | |
| onMount, | |
| type ParentProps, | |
| 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 { debounce } from "@solid-primitives/scheduled" | |
| import { useLocal } from "@/context/local" | |
| import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" | |
| import { createStore } from "solid-js/store" | |
| import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review" | |
| import { ResizeHandle } from "@opencode-ai/ui/resize-handle" | |
| import { Select } from "@opencode-ai/ui/select" | |
| import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" | |
| import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" | |
| import { Tabs } from "@opencode-ai/ui/tabs" | |
| import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" | |
| import { createAutoScroll } from "@opencode-ai/ui/hooks" | |
| import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" | |
| import { Button } from "@opencode-ai/ui/button" | |
| import { showToast } from "@/utils/toast" | |
| import { base64Encode, checksum } from "@opencode-ai/core/util/encode" | |
| import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" | |
| import { NewSessionView, SessionHeader } from "@/components/session" | |
| import { ErrorPage } from "@/pages/error" | |
| import { CommentsProvider, useComments } from "@/context/comments" | |
| import { useCommand } from "@/context/command" | |
| import { DirectoryDataProvider } from "@/pages/directory-layout" | |
| import { useServerSync } from "@/context/server-sync" | |
| import { useLanguage } from "@/context/language" | |
| import { useLayout } from "@/context/layout" | |
| import { ModelsProvider } from "@/context/models" | |
| import { useNotification } from "@/context/notification" | |
| import { PromptProvider, usePrompt } from "@/context/prompt" | |
| import { usePlatform } from "@/context/platform" | |
| import { SDKProvider, useSDK } from "@/context/sdk" | |
| import { useServerSDK } from "@/context/server-sdk" | |
| import { ServerConnection, serverName, useServer } from "@/context/server" | |
| import { useSettings } from "@/context/settings" | |
| import { useSync } from "@/context/sync" | |
| import { useTabs } from "@/context/tabs" | |
| import { TerminalProvider, useTerminal } from "@/context/terminal" | |
| import { PromptInput } from "@/components/prompt-input" | |
| import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" | |
| import { useSettingsCommand } from "@/components/settings-dialog" | |
| import { setCursorPosition } from "@/components/prompt-input/editor-dom" | |
| import { promptLength } from "@/components/prompt-input/history" | |
| import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" | |
| import { | |
| createPromptInputController, | |
| createSessionComposerController, | |
| createSessionComposerRegionController, | |
| SessionComposerRegion, | |
| } from "@/pages/session/composer" | |
| import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers" | |
| import { MessageTimeline } from "@/pages/session/timeline/message-timeline" | |
| import { createTimelineModel } from "@/pages/session/timeline/model" | |
| import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" | |
| import { useSessionLayout } from "@/pages/session/session-layout" | |
| import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" | |
| import { | |
| clampSessionPanelWidth, | |
| SESSION_PANEL_WIDTH_MIN, | |
| sessionPanelWidthMax, | |
| } from "@/pages/session/session-panel-width" | |
| import { SessionSidePanel } from "@/pages/session/session-side-panel" | |
| import { sessionPanelLayout } from "@/pages/session/session-panel-layout" | |
| import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" | |
| import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" | |
| import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" | |
| import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" | |
| import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" | |
| import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" | |
| import { TerminalPanel } from "@/pages/session/terminal-panel" | |
| import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" | |
| import { useComposerCommands } from "@/pages/session/use-composer-commands" | |
| import { useSessionCommands } from "@/pages/session/use-session-commands" | |
| import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" | |
| import { Identifier } from "@/utils/id" | |
| import { diffs as list } from "@/utils/diffs" | |
| import { Persist, persisted } from "@/utils/persist" | |
| import { extractPromptFromParts } from "@/utils/prompt" | |
| import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" | |
| import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" | |
| import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" | |
| import { createSessionOwnership } from "./session/session-ownership" | |
| import { createSessionLineage } from "./session/session-lineage" | |
| type FollowupItem = FollowupDraft & { id: string } | |
| type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context"> | |
| const emptyFollowups: FollowupItem[] = [] | |
| type ChangeMode = "git" | "branch" | "turn" | |
| type VcsMode = "git" | "branch" | |
| const sessionViewState = () => ({ | |
| messageId: undefined as string | undefined, | |
| mobileTab: "session" as "session" | "changes", | |
| }) | |
| function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { | |
| if (!sessionID) return false | |
| return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) | |
| } | |
| async function runPromptRollbackMutation<T, R>(input: { | |
| capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void } | |
| optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void | |
| request: () => Promise<R> | |
| complete: (result: R) => void | |
| rollback: () => void | |
| fail: (error: unknown) => void | |
| }) { | |
| const prompt = input.capturePrompt() | |
| const previous = prompt.current().slice() | |
| batch(() => input.optimistic(prompt)) | |
| await input | |
| .request() | |
| .then(input.complete) | |
| .catch((error) => { | |
| batch(() => { | |
| input.rollback() | |
| prompt.set(previous) | |
| }) | |
| input.fail(error) | |
| }) | |
| } | |
| export function SessionPage() { | |
| return ( | |
| <SessionProviders> | |
| <Page /> | |
| </SessionProviders> | |
| ) | |
| } | |
| // Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed | |
| // remount around the server-scoped providers. Nothing here may key on the | |
| // session ID: session tabs on the same server share this route instance, and | |
| // workspace-scoped state (terminal, directory providers) lives below. | |
| export function TargetSessionRouteContent() { | |
| const params = useParams<{ serverKey: string; id: string }>() | |
| const serverSync = useServerSync() | |
| const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) | |
| return ( | |
| // Settings must keep the target-server SDK, sync, and models context and remain registered | |
| // when session content falls back to the route error boundary. | |
| <TargetServerScopedProviders directory={directory} sessionID={() => params.id}> | |
| <TargetSessionSettingsCommand /> | |
| <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded> | |
| <ResolvedTargetSessionRoute /> | |
| </SessionRouteErrorBoundary> | |
| </TargetServerScopedProviders> | |
| ) | |
| } | |
| function TargetSessionSettingsCommand() { | |
| useSettingsCommand() | |
| return null | |
| } | |
| export function SessionRouteErrorBoundary( | |
| props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, | |
| ) { | |
| const settings = useSettings() | |
| return ( | |
| <ErrorBoundary | |
| fallback={(error) => | |
| settings.general.newLayoutDesigns() ? ( | |
| <SessionRouteFrame padded={props.padded}> | |
| <SessionPanelFrame newLayout raised={!!props.sessionID}> | |
| <SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} /> | |
| </SessionPanelFrame> | |
| </SessionRouteFrame> | |
| ) : ( | |
| <ErrorPage error={error} /> | |
| ) | |
| } | |
| > | |
| {props.children} | |
| </ErrorBoundary> | |
| ) | |
| } | |
| function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) { | |
| const language = useLanguage() | |
| const server = useServer() | |
| const tabs = useTabs() | |
| const displayServer = createMemo(() => { | |
| const key = props.serverKey ?? server.key | |
| const conn = server.list.find((item) => ServerConnection.key(item) === key) | |
| return conn ? serverName(conn) : key | |
| }) | |
| const closeTab = () => { | |
| if (!props.sessionID) return | |
| tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID }) | |
| } | |
| if (isCurrentSessionNotFoundError(props.error, props.sessionID)) { | |
| return ( | |
| <div class="flex-1 min-h-0 overflow-hidden"> | |
| <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4"> | |
| <div class="flex flex-col items-center gap-2"> | |
| <div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div> | |
| <div class="text-13-regular text-text-weak max-w-md"> | |
| {language.t("session.error.notFound.description")} | |
| </div> | |
| </div> | |
| <Show when={props.sessionID}> | |
| {(sessionID) => ( | |
| <div class="max-w-full flex flex-col items-center gap-1"> | |
| <div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div> | |
| <code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]"> | |
| {sessionID()} | |
| </code> | |
| </div> | |
| )} | |
| </Show> | |
| <ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}> | |
| {language.t("session.error.notFound.closeTab")} | |
| </ButtonV2> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| return <ErrorPage error={props.error} /> | |
| } | |
| function ResolvedTargetSessionRoute() { | |
| const params = useParams<{ serverKey: string; id: string }>() | |
| const tabs = useTabs() | |
| const sync = useServerSync() | |
| const serverKey = createMemo(() => requireServerKey(params.serverKey)) | |
| const current = createSessionLineage( | |
| () => params.id, | |
| () => sync().session.lineage, | |
| ) | |
| const directory = createMemo(() => current()?.session.directory) | |
| const targetDirectory = () => directory()! | |
| createEffect(() => { | |
| const session = current() | |
| if (!session) return | |
| tabs.addSessionTab({ | |
| server: serverKey(), | |
| sessionId: session.root.id, | |
| }) | |
| }) | |
| return ( | |
| // Non-keyed: closes only while the target's directory is unknown (uncached | |
| // lineage mid-resolution), which tears down the workspace subtree including | |
| // the terminal. Same-workspace tab switches keep it open because warm | |
| // targets resolve synchronously from the sync cache. | |
| <Show when={directory()}> | |
| <SDKProvider directory={targetDirectory}> | |
| <DirectoryDataProvider directory={targetDirectory} server={serverKey}> | |
| <TargetSessionPage /> | |
| </DirectoryDataProvider> | |
| </SDKProvider> | |
| </Show> | |
| ) | |
| } | |
| // Owns the workspace-identity remount. Must not include the session ID in the | |
| // key: SessionPage handles session changes reactively, and remounting here | |
| // destroys workspace-scoped state (terminal PTYs, file/prompt providers). | |
| function TargetSessionPage() { | |
| const sdk = useSDK() | |
| const serverSDK = useServerSDK() | |
| return ( | |
| <Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed> | |
| <SessionPage /> | |
| </Show> | |
| ) | |
| } | |
| function TargetServerScopedProviders( | |
| props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, | |
| ) { | |
| return ( | |
| <> | |
| <MarkSessionNotificationsViewed sessionID={props.sessionID} /> | |
| <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> | |
| </> | |
| ) | |
| } | |
| function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { | |
| const notification = useNotification() | |
| createEffect(() => { | |
| const sessionID = props.sessionID?.() | |
| if (!notification.ready() || !sessionID) return | |
| if (notification.session.unseenCount(sessionID) === 0) return | |
| notification.session.markViewed(sessionID) | |
| }) | |
| return null | |
| } | |
| function SessionProviders(props: ParentProps) { | |
| return ( | |
| <TerminalProvider> | |
| <FileProvider> | |
| <PromptProvider> | |
| <CommentsProvider>{props.children}</CommentsProvider> | |
| </PromptProvider> | |
| </FileProvider> | |
| </TerminalProvider> | |
| ) | |
| } | |
| function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) { | |
| return ( | |
| <div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}> | |
| {props.children} | |
| </div> | |
| ) | |
| } | |
| function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) { | |
| return ( | |
| <div | |
| classList={{ | |
| "flex-1 min-h-0 flex flex-col": true, | |
| "bg-v2-background-bg-base": props.newLayout, | |
| "bg-background-stronger": !props.newLayout, | |
| "rounded-[10px] overflow-hidden": props.newLayout, | |
| "shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised, | |
| }} | |
| > | |
| {props.children} | |
| </div> | |
| ) | |
| } | |
| export default function Page() { | |
| const serverSync = useServerSync() | |
| const layout = useLayout() | |
| const local = useLocal() | |
| const file = useFile() | |
| const sync = useSync() | |
| const queryClient = useQueryClient() | |
| const dialog = useDialog() | |
| const language = useLanguage() | |
| const sdk = useSDK() | |
| const serverSDK = useServerSDK() | |
| const settings = useSettings() | |
| const platform = usePlatform() | |
| const prompt = usePrompt() | |
| const comments = useComments() | |
| const command = useCommand() | |
| const terminal = useTerminal() | |
| const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() | |
| const location = useLocation() | |
| const navigate = useNavigate() | |
| const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() | |
| const reviewMode = () => view().review.mode() ?? "git" | |
| const reviewFile = () => view().review.file() | |
| const sessionOwnership = createSessionOwnership(sessionKey) | |
| const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) | |
| createEffect(() => { | |
| if (!prompt.ready()) return | |
| untrack(() => { | |
| if (params.id) return | |
| const text = searchParams.prompt | |
| if (!text) return | |
| prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) | |
| setSearchParams({ ...searchParams, prompt: undefined }) | |
| }) | |
| }) | |
| const [ui, setUi] = createStore({ | |
| pendingMessage: undefined as string | undefined, | |
| reviewSnap: false, | |
| scrollGesture: 0, | |
| scroll: { | |
| overflow: false, | |
| bottom: true, | |
| jump: false, | |
| }, | |
| }) | |
| const composer = createSessionComposerController() | |
| const inputController = createPromptInputController({ | |
| sessionKey, | |
| sessionID: () => params.id, | |
| queryOptions: serverSync().queryOptions, | |
| }) | |
| const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) | |
| const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined)) | |
| 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.scope !== serverSDK().scope) return | |
| if (pending.id !== id) return | |
| layout.handoff.clearTabs() | |
| if (pending.dir !== base64Encode(sdk().directory)) return | |
| const from = workspaceTabs().tabs() | |
| if (from.all.length === 0 && !from.active) return | |
| const current = tabs().tabs() | |
| if (current.all.length > 0 || current.active) return | |
| const all = normalizeTabs(from.all) | |
| const active = from.active ? normalizeTab(from.active) : undefined | |
| tabs().setAll(all) | |
| tabs().setActive(active && all.includes(active) ? active : all[0]) | |
| workspaceTabs().setAll([]) | |
| workspaceTabs().setActive(undefined) | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const isDesktop = createMediaQuery("(min-width: 768px)") | |
| const size = createSizing() | |
| const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) | |
| const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) | |
| const terminalOpen = createMemo(() => view().terminal.opened()) | |
| const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen()) | |
| const desktopInlineTerminalOnlyOpen = createMemo( | |
| () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(), | |
| ) | |
| const desktopFileTreeOpen = createMemo( | |
| () => | |
| isDesktop() && | |
| shouldShowFileTree({ | |
| visible: settings.visibility.fileTree(), | |
| opened: layout.fileTree.opened(), | |
| }), | |
| ) | |
| const desktopSessionResizeOpen = createMemo(() => | |
| newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(), | |
| ) | |
| const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) | |
| let panelRow: HTMLDivElement | undefined | |
| const [panelRowWidth, setPanelRowWidth] = createSignal<number>() | |
| createResizeObserver( | |
| () => panelRow, | |
| ({ width }) => setPanelRowWidth(width), | |
| ) | |
| const splitReview = createMemo( | |
| () => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split", | |
| ) | |
| // The observer reports the content-box width, which already excludes the row | |
| // padding; only the flex gap between the panels remains to subtract. | |
| const sessionPanelAvailable = createMemo(() => { | |
| const width = panelRowWidth() | |
| if (width === undefined) return undefined | |
| return width - (settings.general.newLayoutDesigns() ? 8 : 0) | |
| }) | |
| const sessionPanelMax = createMemo(() => { | |
| const available = sessionPanelAvailable() | |
| if (available === undefined) return 1000 | |
| return sessionPanelWidthMax({ available, split: splitReview() }) | |
| }) | |
| // Clamp at render time so window or sidebar resizes squeeze the chat panel | |
| // instead of the review pane, without overwriting the persisted width. | |
| const sessionPanelResizedWidth = createMemo(() => | |
| clampSessionPanelWidth({ | |
| width: layout.session.width(), | |
| available: sessionPanelAvailable(), | |
| split: splitReview(), | |
| }), | |
| ) | |
| const sessionPanelWidth = createMemo(() => { | |
| if (!desktopSidePanelOpen()) return "100%" | |
| if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` | |
| return `calc(100% - ${layout.fileTree.width()}px)` | |
| }) | |
| const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen())) | |
| const desktopV2PanelLayout = createMemo(() => | |
| sessionPanelLayout({ | |
| review: desktopV2ReviewOpen(), | |
| terminal: desktopTerminalOpen(), | |
| files: desktopFileTreeOpen(), | |
| }), | |
| ) | |
| function normalizeTab(tab: string) { | |
| if (!tab.startsWith("file://")) return tab | |
| return file.tab(tab) | |
| } | |
| function normalizeTabs(list: string[]) { | |
| const seen = new Set<string>() | |
| const next: string[] = [] | |
| for (const item of list) { | |
| const value = normalizeTab(item) | |
| if (seen.has(value)) continue | |
| seen.add(value) | |
| next.push(value) | |
| } | |
| return next | |
| } | |
| const openReviewPanel = () => { | |
| if (!view().reviewPanel.opened()) view().reviewPanel.open() | |
| } | |
| const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) | |
| const isChildSession = createMemo(() => !!info()?.parentID) | |
| const canReview = createMemo(() => !!sync().project) | |
| const reviewTab = createMemo(() => isDesktop()) | |
| const tabState = createSessionTabs({ | |
| tabs, | |
| pathFromTab: file.pathFromTab, | |
| normalizeTab, | |
| review: reviewTab, | |
| hasReview: canReview, | |
| }) | |
| const activeTab = tabState.activeTab | |
| const activeFileTab = tabState.activeFileTab | |
| const revertMessageID = createMemo(() => info()?.revert?.messageID) | |
| const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID }) | |
| const historyLoading = timeline.history.loading | |
| const historyMore = timeline.history.more | |
| const lastUserMessage = timeline.lastUserMessage | |
| const messages = timeline.messages | |
| const messagesReady = timeline.ready | |
| const sessionSync = timeline.resource | |
| const userMessages = timeline.userMessages | |
| const visibleUserMessages = timeline.visibleUserMessages | |
| createEffect(() => { | |
| const tab = activeFileTab() | |
| if (!tab) return | |
| const path = file.pathFromTab(tab) | |
| if (path) void file.load(path) | |
| }) | |
| createEffect( | |
| on( | |
| () => lastUserMessage()?.id, | |
| () => { | |
| const msg = lastUserMessage() | |
| if (!msg) return | |
| syncSessionModel(local, msg) | |
| }, | |
| ), | |
| ) | |
| let restoredModelSession: string | undefined | |
| createEffect(() => { | |
| const id = params.id | |
| if (!id || !prompt.ready() || !local.session.ready()) return | |
| if (restoredModelSession !== id) { | |
| restoredModelSession = id | |
| if (restorePromptModel(local, prompt)) return | |
| } | |
| syncPromptModel(local, prompt) | |
| }) | |
| createEffect( | |
| on( | |
| () => ({ dir: sdk().directory, 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({ | |
| ...sessionViewState(), | |
| newSessionWorktree: "main", | |
| deferRender: false, | |
| }) | |
| const [followup, setFollowup] = persisted( | |
| Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]), | |
| createStore<{ | |
| items: Record<string, FollowupItem[] | undefined> | |
| failed: Record<string, string | undefined> | |
| paused: Record<string, boolean | undefined> | |
| edit: Record<string, FollowupEdit | undefined> | |
| }>({ | |
| items: {}, | |
| failed: {}, | |
| paused: {}, | |
| edit: {}, | |
| }), | |
| ) | |
| createComputed((prev) => { | |
| const key = sessionKey() | |
| if (key !== prev) { | |
| setStore("deferRender", true) | |
| const owner = sessionOwnership.capture() | |
| requestAnimationFrame(() => { | |
| setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) | |
| }) | |
| } | |
| return key | |
| }) | |
| let reviewFrame: number | undefined | |
| let todoFrame: number | undefined | |
| let todoTimer: number | undefined | |
| let diffFrame: number | undefined | |
| let diffTimer: number | undefined | |
| createComputed((prev) => { | |
| const open = desktopReviewOpen() | |
| if (prev === undefined || prev === open) return open | |
| if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) | |
| setUi("reviewSnap", true) | |
| reviewFrame = requestAnimationFrame(() => { | |
| reviewFrame = undefined | |
| setUi("reviewSnap", false) | |
| }) | |
| return open | |
| }, desktopReviewOpen()) | |
| const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) | |
| const nogit = createMemo(() => { | |
| const project = sync().project | |
| return !!project && project.vcs !== "git" | |
| }) | |
| const changesOptions = createMemo<ChangeMode[]>(() => { | |
| const list: ChangeMode[] = [] | |
| const project = sync().project | |
| const vcs = sync().data.vcs | |
| if (project?.vcs === "git") list.push("git") | |
| if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { | |
| list.push("branch") | |
| } | |
| list.push("turn") | |
| return list | |
| }) | |
| const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") | |
| const wantsReview = createMemo(() => | |
| isDesktop() | |
| ? desktopFileTreeOpen() || | |
| (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) | |
| : store.mobileTab === "changes", | |
| ) | |
| const vcsMode = createMemo<VcsMode | undefined>(() => { | |
| const mode = reviewMode() | |
| if (mode === "git" || mode === "branch") return mode | |
| }) | |
| const vcsKey = createMemo( | |
| () => | |
| ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, | |
| ) | |
| const vcsQuery = createQuery(() => { | |
| const mode = vcsMode() | |
| const enabled = wantsReview() && sync().project?.vcs === "git" | |
| return { | |
| queryKey: [...vcsKey(), mode] as const, | |
| enabled, | |
| queryFn: mode | |
| ? () => | |
| sdk() | |
| .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) | |
| .then((result) => result.data) | |
| .catch((error) => { | |
| console.debug("[session-review] failed to load vcs diff", { mode, error }) | |
| return [] | |
| }) | |
| : skipToken, | |
| } | |
| }) | |
| const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) | |
| const reviewDiffs = () => { | |
| if (reviewMode() === "git" || reviewMode() === "branch") | |
| // avoids suspense | |
| return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] | |
| return turnDiffs() | |
| } | |
| const activeReviewFile = () => { | |
| const diffs = reviewDiffs() | |
| const selected = reviewFile() | |
| if (selected && diffs.some((diff) => diff.file === selected)) return selected | |
| return diffs[0]?.file | |
| } | |
| const reviewCount = () => reviewDiffs().length | |
| const hasReview = () => reviewCount() > 0 | |
| const reviewReady = () => { | |
| if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending | |
| return true | |
| } | |
| const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => { | |
| const mode = vcsMode() | |
| if (!mode) return | |
| const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) | |
| const directory = reviewDiffDirectory(root, file) | |
| const source = reviewDiffs().find((diff) => diff.file === file) | |
| const valid = (diff: VcsFileDiff | undefined) => { | |
| if (!diff || !source) return | |
| if (diff.additions !== source.additions || diff.deletions !== source.deletions) return | |
| if (reviewDiffNeedsLoad(diff)) return | |
| return diff | |
| } | |
| const request = (scope: string, context?: number) => | |
| queryClient | |
| .fetchQuery({ | |
| queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, | |
| staleTime: Number.POSITIVE_INFINITY, | |
| retry: 2, | |
| queryFn: () => | |
| sdk() | |
| .api.vcs.diff({ | |
| location: { directory: scope }, | |
| mode: mode === "git" ? "working" : mode, | |
| context, | |
| }) | |
| .then((result) => result.data), | |
| }) | |
| .then((diffs) => diffs.find((diff) => diff.file === file)) | |
| if (directory !== root) { | |
| try { | |
| const scoped = valid(await request(directory)) | |
| if (scoped) return scoped | |
| } catch (error) { | |
| console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) | |
| } | |
| } | |
| try { | |
| const bounded = valid(await request(root, 3)) | |
| if (bounded) return bounded | |
| } catch (error) { | |
| console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) | |
| } | |
| } | |
| 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<HTMLElement>("[data-message-id]")] | |
| .map((el) => { | |
| const id = el.dataset.messageId | |
| if (!id) return | |
| const rect = el.getBoundingClientRect() | |
| return { id, top: rect.top, bottom: rect.bottom } | |
| }) | |
| .filter((item): item is { id: string; top: number; bottom: number } => !!item) | |
| const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) | |
| const hit = shown.find((item) => item.top <= line && item.bottom >= line) | |
| if (hit) return hit.id | |
| const near = [...shown].sort((a, b) => { | |
| const da = Math.abs(a.top - line) | |
| const db = Math.abs(b.top - line) | |
| if (da !== db) return da - db | |
| return a.top - b.top | |
| })[0] | |
| if (near) return near.id | |
| return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId | |
| } | |
| function navigateMessageByOffset(offset: number) { | |
| const msgs = visibleUserMessages() | |
| if (msgs.length === 0) return | |
| const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() | |
| const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length | |
| const currentIndex = base === -1 ? msgs.length : base | |
| const targetIndex = currentIndex + offset | |
| if (targetIndex < 0 || targetIndex > msgs.length) return | |
| if (targetIndex === msgs.length) { | |
| resumeScroll() | |
| return | |
| } | |
| autoScroll.pause() | |
| scrollToMessage(msgs[targetIndex], "auto") | |
| } | |
| function upsert(next: Project) { | |
| const list = serverSync().data.project | |
| sync().set("project", next.id) | |
| const idx = list.findIndex((item) => item.id === next.id) | |
| if (idx >= 0) { | |
| serverSync().set( | |
| "project", | |
| list.map((item, i) => (i === idx ? { ...item, ...next } : item)), | |
| ) | |
| return | |
| } | |
| const at = list.findIndex((item) => item.id > next.id) | |
| if (at >= 0) { | |
| serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)]) | |
| return | |
| } | |
| serverSync().set("project", [...list, next]) | |
| } | |
| const gitMutation = useMutation(() => ({ | |
| mutationFn: () => sdk().client.project.initGit(), | |
| onSuccess: (x) => { | |
| if (!x.data) return | |
| upsert(x.data) | |
| }, | |
| onError: (err) => { | |
| showToast({ | |
| variant: "error", | |
| title: language.t("common.requestFailed"), | |
| description: formatServerError(err, language.t), | |
| }) | |
| }, | |
| })) | |
| function initGit() { | |
| if (gitMutation.isPending) return | |
| gitMutation.mutate() | |
| } | |
| let inputRef!: HTMLDivElement | |
| let promptDock: HTMLDivElement | undefined | |
| let dockHeight = 0 | |
| let scroller: HTMLDivElement | undefined | |
| let content: HTMLDivElement | undefined | |
| let revealMessage = (_id: string) => {} | |
| let scrollToEnd = () => {} | |
| 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( | |
| () => { | |
| const id = params.id | |
| return [ | |
| sdk().directory, | |
| id, | |
| id ? (sync().data.session_status[id]?.type ?? "idle") : "idle", | |
| id ? composer.blocked() : false, | |
| ] as const | |
| }, | |
| ([dir, id, status, blocked]) => { | |
| if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) | |
| if (todoTimer !== undefined) window.clearTimeout(todoTimer) | |
| todoFrame = undefined | |
| todoTimer = undefined | |
| if (!id) return | |
| if (status === "idle" && !blocked) return | |
| const cached = untrack(() => sync().data.todo[id] !== undefined) | |
| todoFrame = requestAnimationFrame(() => { | |
| todoFrame = undefined | |
| todoTimer = window.setTimeout(() => { | |
| todoTimer = undefined | |
| if (sdk().directory !== dir || params.id !== id) return | |
| untrack(() => { | |
| void sync().session.todo(id, cached ? { force: true } : undefined) | |
| }) | |
| }, 0) | |
| }) | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| createEffect( | |
| on( | |
| () => visibleUserMessages().at(-1)?.id, | |
| (lastId, prevLastId) => { | |
| if (lastId && prevLastId && lastId > prevLastId) { | |
| setStore("messageId", undefined) | |
| } | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| createEffect( | |
| on( | |
| sessionKey, | |
| () => { | |
| setStore(sessionViewState()) | |
| setUi("pendingMessage", undefined) | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const stopVcs = sdk().event.listen((evt) => { | |
| const details = evt.details as { type: string; properties?: unknown } | |
| if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return | |
| const props = | |
| typeof details.properties === "object" && details.properties | |
| ? (details.properties as Record<string, unknown>) | |
| : undefined | |
| const file = typeof props?.file === "string" ? props.file : undefined | |
| if (!file || file.startsWith(".git/")) return | |
| refreshVcs() | |
| }) | |
| onCleanup(stopVcs) | |
| createEffect( | |
| on( | |
| () => sdk().directory, | |
| (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 | |
| } | |
| const key = scrollKey(event) | |
| if (key) { | |
| if (!scroller || !isScrollKeyTarget(target ?? null, key)) return | |
| if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return | |
| markScrollGesture(scroller) | |
| return | |
| } | |
| if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { | |
| if (composer.blocked() || isChildSession()) return | |
| const input = inputRef | |
| if (!input) return | |
| input.focus() | |
| setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) | |
| } | |
| } | |
| createEffect(() => { | |
| if (!layout.ready()) return | |
| if (sync().status !== "complete") return | |
| if (!sync().project) return | |
| const list = changesOptions() | |
| const mode = reviewMode() | |
| if (list.includes(mode)) return | |
| const next = list[0] | |
| if (!next) return | |
| view().review.setMode(next) | |
| }) | |
| createEffect( | |
| on( | |
| () => sync().data.session_status[params.id ?? ""]?.type, | |
| (next, prev) => { | |
| if (next !== "idle" || prev === undefined || prev === "idle") return | |
| refreshVcs() | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const fileTreeTab = () => layout.fileTree.tab() | |
| const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) | |
| const [tree, setTree] = createStore({ | |
| reviewScroll: undefined as HTMLDivElement | undefined, | |
| pendingDiff: undefined as string | undefined, | |
| }) | |
| createEffect( | |
| on( | |
| sessionKey, | |
| () => { | |
| setTree({ | |
| reviewScroll: undefined, | |
| pendingDiff: undefined, | |
| }) | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const showAllFiles = () => { | |
| if (fileTreeTab() !== "changes") return | |
| setFileTreeTab("all") | |
| } | |
| const focusInput = () => { | |
| if (isChildSession()) return | |
| inputRef?.focus() | |
| } | |
| useComposerCommands() | |
| useSessionCommands({ | |
| navigateMessageByOffset, | |
| setActiveMessage, | |
| focusInput, | |
| review: reviewTab, | |
| fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id, | |
| }) | |
| command.register("session-palette", () => [ | |
| { | |
| id: "command.palette", | |
| title: language.t("command.palette"), | |
| hidden: true, | |
| onSelect: () => command.trigger("file.open", "palette"), | |
| }, | |
| ]) | |
| const openReviewFile = createOpenReviewFile({ | |
| showAllFiles, | |
| tabForPath: file.tab, | |
| openTab: tabs().open, | |
| setActive: tabs().setActive, | |
| loadFile: file.load, | |
| }) | |
| const changesLabel = (option: ChangeMode) => { | |
| if (option === "git") return language.t("ui.sessionReview.title.git") | |
| if (option === "branch") return language.t("ui.sessionReview.title.branch") | |
| return language.t("ui.sessionReview.title.lastTurn") | |
| } | |
| const changesTitle = () => { | |
| if (!canReview()) { | |
| return null | |
| } | |
| return ( | |
| <Select | |
| options={changesOptions()} | |
| current={reviewMode()} | |
| label={changesLabel} | |
| onSelect={(option) => option && view().review.setMode(option)} | |
| variant="ghost" | |
| size="small" | |
| valueClass="text-14-medium" | |
| /> | |
| ) | |
| } | |
| const changesTitleV2 = () => { | |
| if (!canReview()) { | |
| return null | |
| } | |
| return ( | |
| <SelectV2 | |
| appearance="inline" | |
| options={changesOptions()} | |
| current={reviewMode()} | |
| label={changesLabel} | |
| placement="bottom-start" | |
| gutter={6} | |
| onSelect={(option) => option && view().review.setMode(option)} | |
| /> | |
| ) | |
| } | |
| const empty = (text: string) => ( | |
| <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6"> | |
| <div class="text-14-regular text-text-weak max-w-56">{text}</div> | |
| </div> | |
| ) | |
| const createGit = (input: { emptyClass: string }) => ( | |
| <div class={input.emptyClass}> | |
| <div class="flex flex-col gap-3"> | |
| <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div> | |
| <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}> | |
| {language.t("session.review.noVcs.createGit.description")} | |
| </div> | |
| </div> | |
| <Button size="large" disabled={gitMutation.isPending} onClick={initGit}> | |
| {gitMutation.isPending | |
| ? language.t("session.review.noVcs.createGit.actionLoading") | |
| : language.t("session.review.noVcs.createGit.action")} | |
| </Button> | |
| </div> | |
| ) | |
| const reviewEmptyText = createMemo(() => { | |
| if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges") | |
| if (reviewMode() === "branch") return language.t("session.review.noBranchChanges") | |
| return language.t("session.review.noChanges") | |
| }) | |
| const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { | |
| if (reviewMode() === "git" || reviewMode() === "branch") { | |
| if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div> | |
| return empty(reviewEmptyText()) | |
| } | |
| if (reviewMode() === "turn") { | |
| if (nogit()) return createGit(input) | |
| return empty(reviewEmptyText()) | |
| } | |
| return ( | |
| <div class={input.emptyClass}> | |
| <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div> | |
| </div> | |
| ) | |
| } | |
| const reviewEmptyV2 = () => { | |
| if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) { | |
| return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div> | |
| } | |
| if (reviewMode() === "turn" && nogit()) { | |
| return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} /> | |
| } | |
| return <SessionReviewEmptyChangesV2 /> | |
| } | |
| const reviewContent = (input: { | |
| diffStyle: DiffStyle | |
| onDiffStyleChange?: (style: DiffStyle) => void | |
| classes?: SessionReviewTabProps["classes"] | |
| loadingClass: string | |
| emptyClass: string | |
| }) => ( | |
| <Show when={!store.deferRender}> | |
| <SessionReviewTab | |
| title={changesTitle()} | |
| empty={reviewEmpty(input)} | |
| diffs={reviewDiffs} | |
| view={view} | |
| diffStyle={input.diffStyle} | |
| onDiffStyleChange={input.onDiffStyleChange} | |
| onScrollRef={(el) => setTree("reviewScroll", el)} | |
| focusedFile={activeReviewFile()} | |
| onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} | |
| onLineCommentUpdate={updateCommentInContext} | |
| onLineCommentDelete={removeCommentFromContext} | |
| lineCommentActions={reviewCommentActions()} | |
| commentMentions={{ | |
| items: file.searchFilesAndDirectories, | |
| }} | |
| comments={comments.all()} | |
| focusedComment={comments.focus()} | |
| onFocusedCommentChange={comments.setFocus} | |
| onViewFile={openReviewFile} | |
| classes={input.classes} | |
| /> | |
| </Show> | |
| ) | |
| const reviewV2State = createReviewPanelV2State() | |
| // Getters defer reactive reads to the consuming scope. Eager reads here ran inside | |
| // the side panel's Show children and remounted the whole review panel on unrelated | |
| // updates such as session switches. | |
| const reviewPanelV2Props = () => ({ | |
| get title() { | |
| return changesTitleV2() | |
| }, | |
| get empty() { | |
| return reviewEmptyV2() | |
| }, | |
| diffs: reviewDiffs, | |
| diffsReady: reviewReady, | |
| get diffVersion() { | |
| return vcsQuery.dataUpdatedAt | |
| }, | |
| loadDiff: loadReviewDiff, | |
| get activeFile() { | |
| return activeReviewFile() | |
| }, | |
| onSelectFile: focusReviewDiff, | |
| get diffStyle() { | |
| return layout.review.diffStyle() | |
| }, | |
| onDiffStyleChange: layout.review.setDiffStyle, | |
| state: reviewV2State, | |
| onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }), | |
| onLineCommentUpdate: updateCommentInContext, | |
| onLineCommentDelete: removeCommentFromContext, | |
| get lineCommentActions() { | |
| return reviewCommentActions() | |
| }, | |
| get comments() { | |
| return comments.all() | |
| }, | |
| get focusedComment() { | |
| return comments.focus() | |
| }, | |
| onFocusedCommentChange: (focus: { file: string; id: string } | null) => { | |
| // The preview clears the focus once it has opened the comment; persist the | |
| // focused file as the active selection so the preview stays on it. Skip | |
| // files outside the current diff set (their focus is cleared unhandled). | |
| if (!focus) { | |
| const current = comments.focus() | |
| if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file) | |
| } | |
| comments.setFocus(focus) | |
| }, | |
| }) | |
| // Latch: defer only the first diff render off the mount critical path. This Page | |
| // stays mounted across same-workspace session tab switches, so gating on every | |
| // deferRender flip tore down and remounted the whole review pane on tab switch. | |
| const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false) | |
| const reviewPanelV2 = () => ( | |
| <div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict"> | |
| <Show when={reviewPanelV2Rendered()}> | |
| <ReviewPanelV2 {...reviewPanelV2Props()} /> | |
| </Show> | |
| </div> | |
| ) | |
| const reviewPanel = () => ( | |
| <div | |
| classList={{ | |
| "flex flex-col h-full overflow-hidden contain-strict": true, | |
| "bg-v2-background-bg-base": settings.general.newLayoutDesigns(), | |
| "bg-background-stronger": !settings.general.newLayoutDesigns(), | |
| }} | |
| > | |
| <div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> | |
| {reviewContent({ | |
| diffStyle: layout.review.diffStyle(), | |
| onDiffStyleChange: layout.review.setDiffStyle, | |
| 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", | |
| })} | |
| </div> | |
| </div> | |
| ) | |
| createEffect( | |
| on( | |
| activeFileTab, | |
| (active) => { | |
| if (!active) return | |
| if (fileTreeTab() !== "changes") return | |
| showAllFiles() | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const reviewDiffId = (path: string) => { | |
| const sum = checksum(path) | |
| if (!sum) return | |
| return `session-review-diff-${sum}` | |
| } | |
| const reviewDiffTop = (path: string) => { | |
| const root = tree.reviewScroll | |
| if (!root) return | |
| const id = reviewDiffId(path) | |
| if (!id) return | |
| const el = document.getElementById(id) | |
| if (!(el instanceof HTMLElement)) return | |
| if (!root.contains(el)) return | |
| const a = el.getBoundingClientRect() | |
| const b = root.getBoundingClientRect() | |
| return a.top - b.top + root.scrollTop | |
| } | |
| const scrollToReviewDiff = (path: string) => { | |
| const root = tree.reviewScroll | |
| if (!root) return false | |
| const top = reviewDiffTop(path) | |
| if (top === undefined) return false | |
| view().setScroll("review", { x: root.scrollLeft, y: top }) | |
| root.scrollTo({ top, behavior: "auto" }) | |
| return true | |
| } | |
| const focusReviewDiff = (path: string) => { | |
| openReviewPanel() | |
| view().review.openPath(path) | |
| view().review.setFile(path) | |
| setTree("pendingDiff", path) | |
| } | |
| createEffect(() => { | |
| const pending = tree.pendingDiff | |
| if (!pending) return | |
| if (!tree.reviewScroll) return | |
| if (!reviewReady()) return | |
| const attempt = (count: number) => { | |
| if (tree.pendingDiff !== pending) return | |
| if (count > 60) { | |
| setTree("pendingDiff", undefined) | |
| return | |
| } | |
| const root = tree.reviewScroll | |
| if (!root) { | |
| requestAnimationFrame(() => attempt(count + 1)) | |
| return | |
| } | |
| if (!scrollToReviewDiff(pending)) { | |
| requestAnimationFrame(() => attempt(count + 1)) | |
| return | |
| } | |
| const top = reviewDiffTop(pending) | |
| if (top === undefined) { | |
| requestAnimationFrame(() => attempt(count + 1)) | |
| return | |
| } | |
| if (Math.abs(root.scrollTop - top) <= 1) { | |
| setTree("pendingDiff", undefined) | |
| return | |
| } | |
| requestAnimationFrame(() => attempt(count + 1)) | |
| } | |
| requestAnimationFrame(() => attempt(0)) | |
| }) | |
| let treeDir: string | undefined | |
| createEffect(() => { | |
| const dir = sdk().directory | |
| if (!isDesktop()) return | |
| if (!layout.fileTree.opened()) return | |
| if (sync().status === "loading") return | |
| fileTreeTab() | |
| const refresh = treeDir !== dir | |
| treeDir = dir | |
| void (refresh ? file.tree.refresh("") : file.tree.list("")) | |
| }) | |
| createEffect( | |
| on( | |
| () => sdk().directory, | |
| () => { | |
| const tab = activeFileTab() | |
| if (!tab) return | |
| const path = file.pathFromTab(tab) | |
| if (!path) return | |
| void file.load(path, { force: true }) | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const autoScroll = createAutoScroll({ | |
| working: () => true, | |
| overflowAnchor: "none", | |
| }) | |
| createEffect( | |
| on( | |
| () => params.id, | |
| (id, previous) => { | |
| if (!id || !previous || id === previous) return | |
| if (location.hash || store.messageId || ui.pendingMessage) return | |
| autoScroll.resume() | |
| }, | |
| ), | |
| ) | |
| let scrollStateFrame: number | undefined | |
| let scrollStateTarget: HTMLDivElement | undefined | |
| let fillFrame: number | undefined | |
| const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight) | |
| const updateScrollState = (el: HTMLDivElement) => { | |
| const max = el.scrollHeight - el.clientHeight | |
| const distance = max - el.scrollTop | |
| const overflow = max > 1 | |
| const bottom = !overflow || distance <= 2 | |
| const jump = overflow && distance > jumpThreshold(el) | |
| if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return | |
| setUi("scroll", { overflow, bottom, jump }) | |
| } | |
| const scheduleScrollState = (el: HTMLDivElement) => { | |
| scrollStateTarget = el | |
| if (scrollStateFrame !== undefined) return | |
| scrollStateFrame = requestAnimationFrame(() => { | |
| scrollStateFrame = undefined | |
| const target = scrollStateTarget | |
| scrollStateTarget = undefined | |
| if (!target) return | |
| updateScrollState(target) | |
| }) | |
| } | |
| const resumeScroll = () => { | |
| setStore("messageId", undefined) | |
| autoScroll.resume() | |
| scrollToEnd() | |
| clearMessageHash() | |
| const el = scroller | |
| if (el) scheduleScrollState(el) | |
| } | |
| // When the user returns to the bottom, treat the active message as "latest". | |
| createEffect( | |
| on( | |
| autoScroll.userScrolled, | |
| (scrolled) => { | |
| if (scrolled) return | |
| setStore("messageId", undefined) | |
| clearMessageHash() | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| let fill = () => {} | |
| const setScrollRef = (el: HTMLDivElement | undefined) => { | |
| scroller = el | |
| autoScroll.scrollRef(el) | |
| if (!el) return | |
| scheduleScrollState(el) | |
| fill() | |
| } | |
| const markUserScroll = () => { | |
| scrollMark += 1 | |
| } | |
| createResizeObserver( | |
| () => content, | |
| () => { | |
| const el = scroller | |
| if (el) scheduleScrollState(el) | |
| fill() | |
| }, | |
| ) | |
| let captureHistoryAnchor = () => {} | |
| let restoreHistoryAnchor = (_done: boolean) => {} | |
| const historyRequests = new Set<string>() | |
| let historyContinuationFrame: number | undefined | |
| const loadOlder = async () => { | |
| const owner = sessionOwnership.capture() | |
| if (historyLoading() || historyRequests.has(owner.key)) return | |
| historyRequests.add(owner.key) | |
| const before = timeline.messages().length | |
| try { | |
| await timeline.history.loadOlder({ | |
| before: () => owner.run(captureHistoryAnchor), | |
| after: (done) => owner.run(() => restoreHistoryAnchor(done)), | |
| }) | |
| } finally { | |
| historyRequests.delete(owner.key) | |
| } | |
| if (!owner.current() || timeline.messages().length <= before) return | |
| if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return | |
| if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) | |
| historyContinuationFrame = requestAnimationFrame(() => { | |
| historyContinuationFrame = undefined | |
| owner.run(onHistoryScroll) | |
| }) | |
| } | |
| const onHistoryScroll = () => { | |
| if ( | |
| historyRequests.has(sessionOwnership.key()) || | |
| historyLoading() || | |
| !autoScroll.userScrolled() || | |
| !scroller || | |
| scroller.scrollTop >= 200 | |
| ) | |
| return | |
| void loadOlder() | |
| } | |
| onCleanup(() => { | |
| if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame) | |
| }) | |
| fill = () => { | |
| if (fillFrame !== undefined) return | |
| fillFrame = requestAnimationFrame(() => { | |
| fillFrame = undefined | |
| if (!params.id || !messagesReady()) return | |
| if (autoScroll.userScrolled() || historyLoading()) return | |
| const el = scroller | |
| if (!el) return | |
| if (el.scrollHeight > el.clientHeight + 1) return | |
| if (!historyMore()) return | |
| void loadOlder() | |
| }) | |
| } | |
| createEffect( | |
| on( | |
| () => | |
| [ | |
| params.id, | |
| messagesReady(), | |
| historyMore(), | |
| historyLoading(), | |
| autoScroll.userScrolled(), | |
| visibleUserMessages().length, | |
| ] as const, | |
| ([id, ready, more, loading, scrolled]) => { | |
| if (!id || !ready || loading || scrolled) return | |
| if (!more) return | |
| fill() | |
| }, | |
| { defer: true }, | |
| ), | |
| ) | |
| const draft = (id: string) => | |
| extractPromptFromParts(sync().data.part[id] ?? [], { | |
| directory: sdk().directory, | |
| attachmentName: language.t("common.attachment"), | |
| }) | |
| const line = (id: string) => { | |
| const text = draft(id) | |
| .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) | |
| .join("") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| if (text) return text | |
| return `[${language.t("common.attachment")}]` | |
| } | |
| const fail = (err: unknown) => { | |
| showToast({ | |
| variant: "error", | |
| title: language.t("common.requestFailed"), | |
| description: formatServerError(err, language.t), | |
| }) | |
| } | |
| const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next) | |
| const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => { | |
| const session = target.session.get(sessionID) | |
| if (!session) return | |
| target.session.remember({ ...session, revert: next }) | |
| } | |
| const busy = (sessionID: string) => sync().data.session_working(sessionID) | |
| const queuedFollowups = createMemo(() => { | |
| const id = params.id | |
| if (!id) return emptyFollowups | |
| return followup.items[id] ?? emptyFollowups | |
| }) | |
| const editingFollowup = createMemo(() => { | |
| const id = params.id | |
| if (!id) return | |
| return followup.edit[id] | |
| }) | |
| const followupMutation = useMutation(() => ({ | |
| mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { | |
| const owner = sessionOwnership.capture() | |
| const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) | |
| if (!item) return | |
| if (input.manual) setFollowup("paused", input.sessionID, undefined) | |
| setFollowup("failed", input.sessionID, undefined) | |
| const ok = await sendFollowupDraft({ | |
| api: sdk().api.session, | |
| sync: sync(), | |
| serverSync: serverSync(), | |
| draft: item, | |
| optimisticBusy: item.sessionDirectory === sdk().directory, | |
| }).catch((err) => { | |
| setFollowup("failed", input.sessionID, input.id) | |
| fail(err) | |
| return false | |
| }) | |
| if (!ok) return | |
| setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) | |
| if (input.manual) owner.run(resumeScroll) | |
| }, | |
| })) | |
| const followupBusy = (sessionID: string) => | |
| followupMutation.isPending && followupMutation.variables?.sessionID === sessionID | |
| const sendingFollowup = createMemo(() => { | |
| const id = params.id | |
| if (!id) return | |
| if (!followupBusy(id)) return | |
| return followupMutation.variables?.id | |
| }) | |
| const queueEnabled = createMemo(() => { | |
| const id = params.id | |
| if (!id) return false | |
| return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() | |
| }) | |
| const followupText = (item: FollowupDraft) => { | |
| const text = 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) | |
| if (text) return text | |
| return `[${language.t("common.attachment")}]` | |
| } | |
| 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: followupText(item) }))) | |
| const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { | |
| if (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 = params.id | |
| 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 = params.id | |
| if (!id) return | |
| setFollowup("edit", id, undefined) | |
| } | |
| const halt = (sessionID: string) => | |
| busy(sessionID) | |
| ? sdk() | |
| .api.session.interrupt({ sessionID }) | |
| .catch(() => {}) | |
| : Promise.resolve() | |
| const revertMutation = useMutation(() => ({ | |
| mutationFn: async (input: { sessionID: string; messageID: string }) => { | |
| const session = sdk().api.session | |
| const target = sync() | |
| const last = target.session.get(input.sessionID)?.revert | |
| const value = draft(input.messageID) | |
| await runPromptRollbackMutation({ | |
| capturePrompt: prompt.capture, | |
| optimistic: (prompt) => { | |
| roll(input.sessionID, { messageID: input.messageID }, target) | |
| prompt.set(value) | |
| }, | |
| request: () => halt(input.sessionID).then(() => session.revert.stage(input)), | |
| complete: () => undefined, | |
| rollback: () => roll(input.sessionID, last, target), | |
| fail, | |
| }) | |
| }, | |
| })) | |
| const restoreMutation = useMutation(() => ({ | |
| mutationFn: async (id: string) => { | |
| const sessionID = params.id | |
| if (!sessionID) return | |
| const session = sdk().api.session | |
| const target = sync() | |
| const index = userMessages().findIndex((item) => item.id === id) | |
| if (index < 0) return | |
| const next = userMessages()[index + 1] | |
| const last = target.session.get(sessionID)?.revert | |
| await runPromptRollbackMutation({ | |
| capturePrompt: prompt.capture, | |
| optimistic: (promptSession) => { | |
| roll(sessionID, next ? { messageID: next.id } : undefined, target) | |
| if (next) { | |
| promptSession.set(draft(next.id)) | |
| return | |
| } | |
| promptSession.reset() | |
| }, | |
| request: () => | |
| !next | |
| ? halt(sessionID).then(() => session.revert.clear({ sessionID })) | |
| : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), | |
| complete: () => undefined, | |
| rollback: () => roll(sessionID, last, target), | |
| fail, | |
| }) | |
| }, | |
| })) | |
| const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) | |
| const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) | |
| const revert = (input: { sessionID: string; messageID: string }) => { | |
| if (reverting()) return | |
| return revertMutation.mutateAsync(input) | |
| } | |
| const restore = (id: string) => { | |
| if (!params.id || reverting()) return | |
| return restoreMutation.mutateAsync(id) | |
| } | |
| const rolled = createMemo(() => { | |
| const id = revertMessageID() | |
| if (!id) return [] | |
| const index = userMessages().findIndex((item) => item.id === id) | |
| if (index < 0) return [] | |
| return userMessages() | |
| .slice(index) | |
| .map((item) => ({ id: item.id, text: line(item.id) })) | |
| }) | |
| // attachment bytes are embedded as a data URL, so downloading always works; | |
| // revealing requires the on-disk path captured by the client that attached the file | |
| const openAttachment = (file: FilePart) => { | |
| const download = () => { | |
| const anchor = document.createElement("a") | |
| anchor.href = file.url | |
| anchor.download = getFilename(file.filename) || "attachment" | |
| anchor.click() | |
| } | |
| const path = file.filename ?? "" | |
| const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path) | |
| if (platform.revealPath && absolute) { | |
| void platform.revealPath(path).then( | |
| (revealed) => { | |
| if (!revealed) download() | |
| }, | |
| () => download(), | |
| ) | |
| return | |
| } | |
| download() | |
| } | |
| const actions = { revert, openAttachment } | |
| createEffect(() => { | |
| const sessionID = params.id | |
| if (!sessionID) return | |
| const item = queuedFollowups()[0] | |
| if (!item) return | |
| if (followupBusy(sessionID)) return | |
| if (followup.failed[sessionID] === item.id) return | |
| if (followup.paused[sessionID]) return | |
| if (isChildSession()) return | |
| if (composer.blocked()) return | |
| if (busy(sessionID)) return | |
| void sendFollowup(sessionID, item.id) | |
| }) | |
| createResizeObserver( | |
| () => promptDock, | |
| ({ height }) => { | |
| const next = Math.ceil(height) | |
| if (next === dockHeight) return | |
| const el = scroller | |
| const delta = next - dockHeight | |
| const stick = el | |
| ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) | |
| : false | |
| dockHeight = next | |
| if (stick) scrollToEnd() | |
| if (el) scheduleScrollState(el) | |
| fill() | |
| }, | |
| ) | |
| const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ | |
| sessionKey, | |
| sessionID: () => params.id, | |
| messagesReady, | |
| visibleUserMessages, | |
| historyMore, | |
| historyLoading, | |
| loadMore: (sessionID) => sync().session.history.loadMore(sessionID), | |
| currentMessageId: () => store.messageId, | |
| pendingMessage: () => ui.pendingMessage, | |
| setPendingMessage: (value) => setUi("pendingMessage", value), | |
| setActiveMessage, | |
| autoScroll: { | |
| pause: autoScroll.pause, | |
| forceScrollToBottom: () => { | |
| autoScroll.resume() | |
| scrollToEnd() | |
| }, | |
| }, | |
| scroller: () => scroller, | |
| anchor, | |
| revealMessage: (id) => revealMessage(id), | |
| scheduleScrollState, | |
| consumePendingMessage: layout.pendingMessage.consume, | |
| }) | |
| createEffect( | |
| on( | |
| () => params.id, | |
| (id) => { | |
| if (!id) requestAnimationFrame(() => inputRef?.focus()) | |
| }, | |
| ), | |
| ) | |
| onMount(() => { | |
| makeEventListener(document, "keydown", handleKeyDown) | |
| }) | |
| onCleanup(() => { | |
| if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) | |
| if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) | |
| if (todoTimer !== undefined) window.clearTimeout(todoTimer) | |
| if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) | |
| if (diffTimer !== undefined) window.clearTimeout(diffTimer) | |
| if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) | |
| if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) | |
| }) | |
| useUsageExceededDialogs() | |
| const mobileTabs = (compact = false, bottom = false) => ( | |
| <Tabs value={store.mobileTab} class="h-auto"> | |
| <Tabs.List | |
| classList={{ | |
| "!h-9": compact, | |
| "[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom, | |
| }} | |
| > | |
| <Tabs.Trigger | |
| value="session" | |
| classList={{ | |
| "!w-1/2 !max-w-none": true, | |
| "!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom, | |
| }} | |
| classes={{ button: compact ? "w-full !py-2" : "w-full" }} | |
| onClick={() => setStore("mobileTab", "session")} | |
| > | |
| {language.t("session.tab.session")} | |
| </Tabs.Trigger> | |
| <Tabs.Trigger | |
| value="changes" | |
| classList={{ | |
| "!w-1/2 !max-w-none !border-r-0": true, | |
| "!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom, | |
| }} | |
| classes={{ button: compact ? "w-full !py-2" : "w-full" }} | |
| onClick={() => setStore("mobileTab", "changes")} | |
| > | |
| {hasReview() | |
| ? language.t("session.review.filesChanged", { count: reviewCount() }) | |
| : language.t("session.review.change.other")} | |
| </Tabs.Trigger> | |
| </Tabs.List> | |
| </Tabs> | |
| ) | |
| const mobileTabsBottom = createMemo( | |
| () => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom", | |
| ) | |
| const sessionErrorFallback = (error: unknown, reset: () => void) => { | |
| createEffect(on(sessionKey, reset, { defer: true })) | |
| return <SessionErrorFallback error={error} sessionID={params.id} /> | |
| } | |
| const sessionPanelContent = () => ( | |
| <> | |
| {sessionSync() ?? ""} | |
| <Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}> | |
| {mobileTabs(true)} | |
| </Show> | |
| <div class="flex-1 min-h-0 overflow-hidden"> | |
| <Switch> | |
| <Match when={params.id && mobileChanges()}> | |
| <div class="relative h-full overflow-hidden"> | |
| {reviewContent({ | |
| diffStyle: "unified", | |
| classes: { | |
| root: "pb-8 [&_[data-slot=session-review-list]]:pb-0", | |
| header: "px-4 !h-16 !pb-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", | |
| })} | |
| </div> | |
| </Match> | |
| <Match when={params.id}> | |
| <Show when={messagesReady() ? params.id : undefined} keyed> | |
| {(_id) => ( | |
| <MessageTimeline | |
| actions={actions} | |
| scroll={ui.scroll} | |
| onResumeScroll={resumeScroll} | |
| setScrollRef={setScrollRef} | |
| onScheduleScrollState={scheduleScrollState} | |
| onAutoScrollHandleScroll={autoScroll.handleScroll} | |
| onMarkScrollGesture={markScrollGesture} | |
| hasScrollGesture={hasScrollGesture} | |
| onUserScroll={markUserScroll} | |
| onHistoryScroll={onHistoryScroll} | |
| onAutoScrollInteraction={autoScroll.handleInteraction} | |
| shouldAnchorBottom={() => | |
| !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() | |
| } | |
| centered={centered()} | |
| setContentRef={(el) => { | |
| content = el | |
| autoScroll.contentRef(el) | |
| const root = scroller | |
| if (root) scheduleScrollState(root) | |
| }} | |
| userMessages={visibleUserMessages()} | |
| setHistoryAnchor={(handlers) => { | |
| captureHistoryAnchor = handlers.capture | |
| restoreHistoryAnchor = handlers.restore | |
| }} | |
| anchor={anchor} | |
| setRevealMessage={(fn) => { | |
| revealMessage = fn | |
| }} | |
| setScrollToEnd={(fn) => { | |
| scrollToEnd = fn | |
| }} | |
| /> | |
| )} | |
| </Show> | |
| </Match> | |
| <Match when={true}> | |
| <NewSessionView worktree={newSessionWorktree()} /> | |
| </Match> | |
| </Switch> | |
| </div> | |
| <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}> | |
| {(_) => { | |
| const controller = createSessionComposerRegionController({ | |
| state: composer, | |
| sessionKey, | |
| sessionID: () => params.id, | |
| prompt, | |
| ready: () => !store.deferRender && messagesReady(), | |
| centered, | |
| todo: { | |
| collapsed: () => view().todoCollapsed.get(), | |
| onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()), | |
| }, | |
| followup: () => | |
| params.id && !isChildSession() | |
| ? { | |
| items: followupDock(), | |
| sending: sendingFollowup(), | |
| onSend: (id) => void sendFollowup(params.id!, id, { manual: true }), | |
| onEdit: editFollowup, | |
| } | |
| : undefined, | |
| revert: () => | |
| rolled().length > 0 | |
| ? { | |
| items: rolled(), | |
| restoring: restoring(), | |
| disabled: reverting(), | |
| onRestore: restore, | |
| } | |
| : undefined, | |
| onResponseSubmit: resumeScroll, | |
| openParent: () => { | |
| const id = info()?.parentID | |
| if (!id) return | |
| navigate( | |
| params.serverKey | |
| ? sessionHref(requireServerKey(params.serverKey), id) | |
| : legacySessionHref(sdk().directory, id), | |
| ) | |
| }, | |
| setPromptRef: (el) => { | |
| inputRef = el | |
| }, | |
| setDockRef: (el) => { | |
| promptDock = el | |
| }, | |
| }) | |
| return ( | |
| <SessionComposerRegion | |
| controller={controller} | |
| promptInput={ | |
| <Show | |
| when={newSessionDesign()} | |
| fallback={ | |
| <PromptInput | |
| controls={inputController()} | |
| ref={(el) => { | |
| inputRef = el | |
| }} | |
| newSessionWorktree={newSessionWorktree()} | |
| onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} | |
| onSubmit={() => { | |
| comments.clear() | |
| resumeScroll() | |
| }} | |
| edit={editingFollowup()} | |
| onEditLoaded={clearFollowupEdit} | |
| shouldQueue={queueEnabled} | |
| onQueue={queueFollowup} | |
| onAbort={() => { | |
| const id = params.id | |
| if (!id) return | |
| setFollowup("paused", id, true) | |
| }} | |
| /> | |
| } | |
| > | |
| {(_) => { | |
| const controller = usePromptInputV2Controller({ | |
| get controls() { | |
| return inputController() | |
| }, | |
| ref: (el) => { | |
| inputRef = el | |
| }, | |
| get newSessionWorktree() { | |
| return newSessionWorktree() | |
| }, | |
| onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"), | |
| onSubmit: () => { | |
| comments.clear() | |
| resumeScroll() | |
| }, | |
| get edit() { | |
| return editingFollowup() | |
| }, | |
| onEditLoaded: clearFollowupEdit, | |
| shouldQueue: queueEnabled, | |
| onQueue: queueFollowup, | |
| onAbort: () => { | |
| const id = params.id | |
| if (!id) return | |
| setFollowup("paused", id, true) | |
| }, | |
| }) | |
| return <PromptInputV2Composer controller={controller} borderUnderlay /> | |
| }} | |
| </Show> | |
| } | |
| /> | |
| ) | |
| }} | |
| </Show> | |
| <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show> | |
| </> | |
| ) | |
| return ( | |
| <SessionRouteFrame> | |
| <SessionHeader /> | |
| <div | |
| ref={panelRow} | |
| class="flex-1 min-h-0 flex flex-col md:flex-row" | |
| classList={{ | |
| "gap-2 p-2": settings.general.newLayoutDesigns(), | |
| }} | |
| > | |
| <Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show> | |
| <div | |
| classList={{ | |
| "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true, | |
| "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": | |
| !size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(), | |
| }} | |
| style={{ | |
| width: sessionPanelWidth(), | |
| }} | |
| > | |
| {settings.general.newLayoutDesigns() ? ( | |
| <Show when={sessionPanelKey()} keyed> | |
| {(_) => ( | |
| <SessionPanelFrame newLayout raised={!!params.id}> | |
| <ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary> | |
| </SessionPanelFrame> | |
| )} | |
| </Show> | |
| ) : ( | |
| <SessionPanelFrame newLayout={false} raised={!!params.id}> | |
| {sessionPanelContent()} | |
| </SessionPanelFrame> | |
| )} | |
| <Show when={desktopSessionResizeOpen()}> | |
| <div onPointerDown={() => size.start()}> | |
| <ResizeHandle | |
| classList={{ | |
| "-end-1": settings.general.newLayoutDesigns(), | |
| }} | |
| direction="horizontal" | |
| size={sessionPanelResizedWidth()} | |
| min={SESSION_PANEL_WIDTH_MIN} | |
| max={sessionPanelMax()} | |
| onResize={(width) => { | |
| size.touch() | |
| layout.session.resize(width) | |
| }} | |
| /> | |
| </div> | |
| </Show> | |
| </div> | |
| <Show when={!newSessionDesign() && desktopSidePanelOpen()}> | |
| <Suspense> | |
| <SessionSidePanel | |
| canReview={canReview} | |
| diffs={reviewDiffs} | |
| diffsReady={reviewReady} | |
| empty={reviewEmptyText} | |
| hasReview={hasReview} | |
| reviewHasFocusableContent={hasReview} | |
| reviewCount={reviewCount} | |
| reviewPanel={reviewPanel} | |
| activeDiff={activeReviewFile()} | |
| focusReviewDiff={focusReviewDiff} | |
| reviewSnap={ui.reviewSnap} | |
| size={size} | |
| /> | |
| </Suspense> | |
| </Show> | |
| <Show when={newSessionDesign()}> | |
| <Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}> | |
| <div class="min-w-0 h-full flex flex-1 flex-col"> | |
| <Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}> | |
| <div class="min-h-0 flex-1"> | |
| <Suspense> | |
| <SessionSidePanel | |
| canReview={canReview} | |
| diffs={reviewDiffs} | |
| diffsReady={reviewReady} | |
| empty={reviewEmptyText} | |
| hasReview={hasReview} | |
| reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} | |
| reviewCount={reviewCount} | |
| reviewPanel={reviewPanelV2} | |
| reviewSidebarToggle={(disabled) => ( | |
| <SessionReviewV2SidebarToggle | |
| opened={reviewV2State.sidebarOpened()} | |
| disabled={disabled} | |
| onToggle={reviewV2State.toggleSidebar} | |
| /> | |
| )} | |
| fileBrowserState={reviewV2State} | |
| activeDiff={activeReviewFile()} | |
| focusReviewDiff={focusReviewDiff} | |
| reviewSnap={ui.reviewSnap} | |
| size={size} | |
| stacked={desktopV2PanelLayout().stacked} | |
| /> | |
| </Suspense> | |
| </div> | |
| </Show> | |
| <Show when={desktopV2PanelLayout().stacked}> | |
| <div class="relative h-2 shrink-0" onPointerDown={() => size.start()}> | |
| <ResizeHandle | |
| class="!relative !inset-auto !h-full !w-full !transform-none" | |
| direction="vertical" | |
| size={layout.terminal.height()} | |
| min={100} | |
| max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6} | |
| collapseThreshold={50} | |
| onResize={(height) => { | |
| size.touch() | |
| layout.terminal.resize(height) | |
| }} | |
| onCollapse={() => view().terminal.close()} | |
| /> | |
| </div> | |
| </Show> | |
| <Show when={terminalOpen()}> | |
| <div | |
| classList={{ | |
| "min-h-0 shrink-0": desktopV2PanelLayout().stacked, | |
| "min-h-0 flex-1": !desktopV2PanelLayout().stacked, | |
| }} | |
| > | |
| <TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} /> | |
| </div> | |
| </Show> | |
| </div> | |
| </Show> | |
| </Show> | |
| </div> | |
| <Show when={!newSessionDesign()}> | |
| <TerminalPanel /> | |
| </Show> | |
| </SessionRouteFrame> | |
| ) | |
| } | |