+21







![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)
![opencode-agent[bot]](/assets/img/avatar_default.png)
opencode-agent[bot]
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aiden Cline
usrnk1
Aarav Sareen
Luke Parker
Brendan Allan
Kit Langton
James Long
Adam
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Jay
Simon Klee
Jay
Jack
David Hill
Brendan Allan
opencode
Aiden Cline
James Long
Dustin Deus
冯基魁
Frank
Aiden Cline
Victor Navarro
Dax Raad
Dax
Nabs
Vladimir Glafirov
AidenGeunGeun
Mark
634386fe0f
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com>
2319 lines
75 KiB
TypeScript
2319 lines
75 KiB
TypeScript
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,
|
|
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 { 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 { PermissionProvider } from "@/context/permission"
|
|
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 { 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 }>()
|
|
return (
|
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
|
<ResolvedTargetSessionRoute />
|
|
</SessionRouteErrorBoundary>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
|
{/* 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>
|
|
</TargetServerScopedProviders>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<PermissionProvider directory={props.directory}>
|
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
</PermissionProvider>
|
|
)
|
|
}
|
|
|
|
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 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 diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
|
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 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")
|
|
: 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()
|
|
.client.vcs.diff({ mode })
|
|
.then((result) => list(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()
|
|
.client.vcs.diff({ mode, directory: scope, 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(
|
|
() => 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) => {
|
|
if (evt.details.type !== "filesystem.changed") return
|
|
const props =
|
|
typeof evt.details.properties === "object" && evt.details.properties
|
|
? (evt.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,
|
|
})
|
|
|
|
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))
|
|
})
|
|
|
|
createEffect(() => {
|
|
const id = params.id
|
|
if (!id) return
|
|
|
|
if (!wantsReview()) return
|
|
if (sync().data.session_diff[id] !== undefined) return
|
|
if (sync().status === "loading") return
|
|
|
|
void sync().session.diff(id)
|
|
})
|
|
|
|
createEffect(
|
|
on(
|
|
() => [sessionKey(), 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 = params.id
|
|
if (!id) return
|
|
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
|
|
|
diffFrame = requestAnimationFrame(() => {
|
|
diffFrame = undefined
|
|
diffTimer = window.setTimeout(() => {
|
|
diffTimer = undefined
|
|
if (sessionKey() !== key) return
|
|
void sync().session.diff(id, { force: true })
|
|
}, 0)
|
|
})
|
|
},
|
|
{ defer: true },
|
|
),
|
|
)
|
|
|
|
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({
|
|
client: sdk().client,
|
|
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()
|
|
.client.session.abort({ sessionID })
|
|
.catch(() => {})
|
|
: Promise.resolve()
|
|
|
|
const revertMutation = useMutation(() => ({
|
|
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
|
const client = sdk().client
|
|
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(() => client.session.revert(input)),
|
|
complete: (result) => {
|
|
if (result.data) merge(result.data, target)
|
|
},
|
|
rollback: () => roll(input.sessionID, last, target),
|
|
fail,
|
|
})
|
|
},
|
|
}))
|
|
|
|
const restoreMutation = useMutation(() => ({
|
|
mutationFn: async (id: string) => {
|
|
const sessionID = params.id
|
|
if (!sessionID) return
|
|
|
|
const client = sdk().client
|
|
const target = sync()
|
|
const next = userMessages().find((item) => item.id > id)
|
|
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(() => client.session.unrevert({ sessionID }))
|
|
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
|
complete: (result) => {
|
|
if (result.data) merge(result.data, target)
|
|
},
|
|
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 []
|
|
return userMessages()
|
|
.filter((item) => item.id >= id)
|
|
.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 (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
|
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
|
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
|
})
|
|
|
|
useUsageExceededDialogs()
|
|
|
|
const composerRegion = () => {
|
|
const controller = createSessionComposerRegionController({
|
|
state: composer,
|
|
sessionKey,
|
|
sessionID: () => params.id,
|
|
prompt,
|
|
centered,
|
|
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={
|
|
<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 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()}>{(_) => composerRegion()}</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={{
|
|
"-right-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()}>
|
|
<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}
|
|
/>
|
|
</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">
|
|
<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}
|
|
/>
|
|
</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>
|
|
)
|
|
}
|