Files
CodeNomad/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
VooDisss 9bf4d351de Refactor Git Changes workflow and diff handling (#311)
# Git Changes PR Review Context

Fixes: #310 

## Purpose of this document

This document is intended to give a PR reviewer or gatekeeper enough
neutral context to review the Git Changes feature series accurately.

## BEFORE/AFTER SNAPSHOT:

<img width="835" height="1163" alt="image"
src="https://github.com/user-attachments/assets/463d6f8c-1a6b-4cf0-8ab8-44a92c534ca5"
/>


It distinguishes:

1. the intended scope of the work
2. implementation choices that were deliberate
3. behaviors that were explicitly tested and accepted during development
4. remaining follow-up areas that were not part of the required intent

It should not be treated as a request to approve the PR automatically.
It exists to reduce false-positive review findings caused by missing
context.

---

## High-level scope

The work in this series refactors and extends the existing `Git Changes`
tab in the right panel.

The intended feature scope includes:

1. grouped staged / unstaged change presentation
2. correct section-aware diff loading
3. per-file stage / unstage controls
4. commit message compose box and commit action for staged changes
5. prompt-context insertion from the Git diff viewer
6. auto-refresh behavior that reduces dependence on the manual refresh
button

This work is intentionally implemented inside the existing Git Changes
vertical slice rather than as a new SCM subsystem.

---

## Files and areas intentionally changed

### Server / API surface

The following server areas were intentionally extended:

1. `packages/server/src/api-types.ts`
2. `packages/server/src/events/bus.ts`
3. `packages/server/src/server/http-server.ts`
4. `packages/server/src/server/routes/workspaces.ts`
5. `packages/server/src/workspaces/git-status.ts`
6. `packages/server/src/workspaces/git-mutations.ts`
7. `packages/server/src/workspaces/worktree-directory.ts`
8. `packages/server/src/workspaces/instance-events.ts`

### UI surface

The following UI areas were intentionally extended:

1. `packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx`
2. `packages/ui/src/components/instance/instance-shell2.tsx`
3.
`packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`
4.
`packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts`
5.
`packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx`
6. `packages/ui/src/components/instance/shell/right-panel/types.ts`
7. `packages/ui/src/components/instance/shell/storage.ts`
8. `packages/ui/src/components/prompt-input.tsx`
9. `packages/ui/src/components/prompt-input/types.ts`
10. `packages/ui/src/components/session/session-view.tsx`
11. `packages/ui/src/lib/api-client.ts`
12. `packages/ui/src/lib/i18n/messages/*/instance.ts`
13. `packages/ui/src/styles/panels/right-panel.css`

---

## Intentional product and architecture decisions

The following outcomes were deliberate and should not be flagged as
issues merely because they exist.

### Git status / diff architecture

1. The UI does not rely only on the proxied OpenCode `file.status()`
payload.
2. CodeNomad adds server-backed worktree Git status and diff endpoints
to expose staged / unstaged semantics correctly.
3. Server-backed worktree mutation endpoints were added for:
   - stage
   - unstage
   - commit
4. The existing event bus / SSE channel is reused for Git invalidation,
instead of adding a bespoke invalidation route.

### Git Changes UI structure

1. The file list is grouped into:
   - `Staged Changes`
   - `Changes`
2. Both sections are collapsible.
3. Section open state is persisted.
4. The same file may appear in both sections when Git state genuinely
requires that.
5. Rows are filename-first, with parent path as secondary text.
6. Rows are intentionally compact compared to the original flat list.

### Diff behavior

1. Diff loading is section-aware.
2. Deleted files are supported in grouped mode.
3. Binary files are treated as non-line-oriented in the diff viewer.
4. Binary diffs suppress line-based prompt-context affordances.

### Stage / unstage / commit workflow

1. Stage and unstage are per-file row actions.
2. Bulk stage-all / unstage-all was intentionally not added.
3. The commit compose box is intentionally rendered inside the `Staged
Changes` section.
4. The commit button is intentionally overlaid inside the commit input
area.
5. The current commit compose flow is minimal by design:
   - no push
   - no amend flow
   - no branch management

### Prompt-context insertion

1. Prompt insertion is intentionally an HTML comment marker, not a full
diff payload.
2. The expected inserted form is:

   `<!-- Git change context: <path> lines X-Y -->`

3. The trigger UI is intentionally a seam/gutter action in the Monaco
diff viewer, not a toolbar button.

### Row action reveal behavior

1. Stage / unstage row actions are intentionally hover-revealed on
hover-capable layouts.
2. The row action reveal intentionally uses:
   - delayed hide
   - slight stats fade/shift
   - compact idle width
3. On non-hover layouts, the action remains visible for reliability.

### Auto-refresh behavior

The accepted refresh model is intentionally hybrid:

1. refresh on Git Changes tab activation
2. 20-second polling only while the Git Changes tab is active
3. immediate invalidation from completed raw tool events for:
   - `write`
   - `edit`
   - `apply_patch`

This hybrid model is intentional. Polling remains as a fallback even
after tool-event invalidation.

---

## Behaviors explicitly tested during development

The following behaviors were explicitly exercised during development and
used to guide fixes.

### Grouped staged / unstaged behavior

1. files appear in the correct staged / unstaged sections
2. section collapse / expand works
3. collapse state persists
4. line counts are section-specific

### Diff behavior

1. staged diff loads differently from unstaged diff
2. deleted-file handling was verified and corrected
3. binary-file rendering was corrected to avoid line-oriented behavior
4. untracked binary files no longer report fake text line counts

### Mutation behavior

1. per-file stage works from `Changes`
2. per-file unstage works from `Staged Changes`
3. stage / unstage selection remapping was exercised and corrected
4. unborn-repo unstage behavior was explicitly hardened

### Prompt-context behavior

1. selected line / range insertion was tested
2. button placement in the Monaco seam/gutter was iterated and verified

### Auto-refresh behavior

1. tab-activation refresh was tested
2. 20-second active-tab polling was tested
3. raw completed tool invalidation was tested in the running UI for:
   - `write`
   - `edit`
   - `apply_patch`
4. stale async overwrite and stale selection restoration bugs were found
and fixed through review/testing

---

## Review findings that were investigated and are no longer intended
blocker topics

The following areas were previously raised by strict reviews and then
either fixed or determined to be acceptable within scope.

### Fixed in the current series

1. duplicate stage / unstage firing
2. stale diff response overwriting newer selection
3. passive refresh restoring a stale selection
4. instance-wide invalidation overreach
5. selected diff staying stale after tool invalidation
6. worktree-switch status races
7. unhandled rejection risk from async invalidation publication
8. queued invalidation intent being lost during in-flight refresh
9. `git-diff` path traversal / absolute path boundary issue

### Investigated and considered non-blocking within current intent

1. split add/delete presentation for tracked rename behavior
   - this was compared against VS Code behavior during manual testing
   - no stage/unstage corruption was observed in the tested flow
- this is currently treated as a representation tradeoff, not a proven
blocker

---

## Remaining non-blocker follow-up areas

The following are still reasonable follow-up topics, but they were not
part of the required blocker-fix scope.

1. normalize directory-to-worktree matching more aggressively on Windows
so tool invalidation works more reliably from nested directories or
path-format variations
2. improve keyboard discoverability of hover-revealed stage / unstage
actions
3. reserve textarea space for the overlaid commit button if the overlay
tradeoff is reconsidered
4. reduce size/complexity in:
   - `RightPanel.tsx`
   - `right-panel.css`
5. tighten raw SSE tool-event parsing into a more explicit helper if
that event bridge grows further

These follow-ups should not be interpreted as evidence that the core
implementation is incomplete unless a reviewer finds a new concrete
failure.

---

## Suggested review focus

If a gatekeeper or reviewer is evaluating this PR, the most useful focus
areas are:

1. whether staged / unstaged behavior is correct for normal Git
workflows
2. whether the new server worktree Git endpoints remain narrowly scoped
3. whether auto-refresh remains bounded to the active Git Changes
context
4. whether the explicit fixes for stale async behavior and invalidation
races are sufficient
5. whether any unintentional server boundary broadening or state
corruption remains

Less useful review topics, unless tied to a concrete failure, are:

1. preference disagreements with accepted prompt insertion format
2. preference disagreements with the overlaid commit button placement
3. preference disagreements with keeping polling fallback alongside tool
invalidation
4. objections to server-backed Git endpoints purely because they add
surface area

---

## Summary

This series intentionally evolves the existing Git Changes tab into a
more complete source-control workflow for:

1. grouped staged / unstaged inspection
2. section-aware diffs
3. per-file staging and unstaging
4. commit composition for staged changes
5. prompt-context insertion from Git diffs
6. bounded auto-refresh for both passive viewing and agent-driven file
mutations

The intended review standard is to find concrete correctness, layering,
or maintenance problems that remain after this series — not to re-argue
the already accepted product choices listed above.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-16 23:11:48 +01:00

958 lines
36 KiB
TypeScript

import {
Show,
Suspense,
createEffect,
createMemo,
createSignal,
lazy,
onCleanup,
type Accessor,
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import {
getDefaultWorktreeSlug,
getGitRepoStatus,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
getWorktrees,
} from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { useGitChanges } from "./useGitChanges"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool,
readStoredEnum,
readStoredPanelWidth,
readStoredRightPanelTab,
} from "../storage"
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
function RightPanelTabFallback() {
return <div class="flex-1 min-h-0" />
}
interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
isPhoneLayout: Accessor<boolean>
rightDrawerWidth: Accessor<number>
rightDrawerWidthInitialized: Accessor<boolean>
rightDrawerState: Accessor<DrawerViewState>
rightPinned: Accessor<boolean>
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
promptInputApi: Accessor<PromptInputApi | null>
setContentEl: (el: HTMLElement | null) => void
}
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"yolo-mode",
"plan",
"background-processes",
"mcp",
"lsp",
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
const [browserLoading, setBrowserLoading] = createSignal(false)
const [browserError, setBrowserError] = createSignal<string | null>(null)
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
)
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
const [filesListOpen, setFilesListOpen] = createSignal(true)
const [filesListTouched, setFilesListTouched] = createSignal(false)
const [changesListOpen, setChangesListOpen] = createSignal(true)
const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
const layout = listLayoutKey()
if (tab === "changes") {
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
}
if (tab === "git-changes") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
}
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
const layout = listLayoutKey()
if (section === "staged") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
}
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
}
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
}
createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey()
layout
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
if (filesPersisted !== null) {
setFilesListOpen(filesPersisted)
setFilesListTouched(true)
} else {
setFilesListOpen(true)
setFilesListTouched(false)
}
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
if (changesPersisted !== null) {
setChangesListOpen(changesPersisted)
setChangesListTouched(true)
} else {
setChangesListOpen(true)
setChangesListTouched(false)
}
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
if (gitPersisted !== null) {
setGitChangesListOpen(gitPersisted)
setGitChangesListTouched(true)
} else {
setGitChangesListOpen(true)
setGitChangesListTouched(false)
}
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
setGitStagedOpen(stagedPersisted ?? true)
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
setGitUnstagedOpen(unstagedPersisted ?? true)
})
createEffect(() => {
// Default behavior: when nothing is selected, keep the file list open.
// Once the user explicitly toggles it, we stop auto-opening.
if (rightPanelTab() !== "files") return
if (filesListTouched()) return
if (!browserSelectedPath()) {
setFilesListOpen(true)
}
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
const max = Math.min(560, maxByDrawer)
return Math.min(max, Math.max(min, Math.floor(value)))
}
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
createEffect(() => {
if (splitWidthsInitialized()) return
if (!props.rightDrawerWidthInitialized()) return
setSplitWidthsInitialized(true)
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
})
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
if (typeof window === "undefined") return
const key =
mode === "changes"
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
: mode === "git-changes"
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
window.localStorage.setItem(key, String(width))
}
function stopSplitResize() {
setActiveSplitResize(null)
if (typeof document === "undefined") return
splitPointerDrag.stop()
}
function splitMouseMove(event: MouseEvent) {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitMouseUp() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
function splitTouchMove(event: TouchEvent) {
const mode = activeSplitResize()
if (!mode) return
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitTouchEnd() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
const splitPointerDrag = useGlobalPointerDrag({
onMouseMove: splitMouseMove,
onMouseUp: splitMouseUp,
onTouchMove: splitTouchMove,
onTouchEnd: splitTouchEnd,
})
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
if (typeof document === "undefined") return
setActiveSplitResize(mode)
setSplitResizeStartX(clientX)
setSplitResizeStartWidth(
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
)
splitPointerDrag.start()
}
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
event.preventDefault()
startSplitResize(mode, event.clientX)
}
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startSplitResize(mode, touch.clientX)
}
onCleanup(() => {
stopSplitResize()
})
const worktreeSlugForViewer = createMemo(() => {
const sessionId = props.activeSessionId()
if (sessionId && sessionId !== "info") {
return getWorktreeSlugForSession(props.instanceId, sessionId)
}
return getDefaultWorktreeSlug(props.instanceId)
})
const gitChangesWorktreeSlug = createMemo(() => {
if (getGitRepoStatus(props.instanceId) === false) return null
const slug = worktreeSlugForViewer().trim()
return slug ? slug : null
})
const gitChangesWorktree = createMemo(() => {
const slug = gitChangesWorktreeSlug()
if (!slug) return null
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
})
const gitChangesBranchLabel = createMemo(() => {
const branch = gitChangesWorktree()?.branch?.trim()
return branch || null
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile,
unstageGitFile,
} = useGitChanges({
t: props.t,
instanceId: props.instanceId,
rightPanelTab,
worktreeSlug: worktreeSlugForViewer,
isPhoneLayout: props.isPhoneLayout,
promptInputApi: props.promptInputApi,
closeGitList: () => setGitChangesListOpen(false),
})
createEffect(() => {
worktreeSlugForViewer()
setBrowserPath(".")
setBrowserEntries(null)
setBrowserError(null)
setBrowserSelectedPath(null)
setBrowserSelectedContent(null)
setBrowserSelectedError(null)
setBrowserSelectedLoading(false)
})
const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
const best = diffs.reduce((currentBest, item) => {
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
}, diffs[0])
return typeof (best as any)?.file === "string" ? (best as any).file : null
})
createEffect(() => {
const next = bestDiffFile()
if (!next) return
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return
const current = selectedFile()
if (current && diffs.some((d) => d.file === current)) return
setSelectedFile(next)
})
const normalizeBrowserPath = (input: string) => {
const raw = String(input || ".").trim()
if (!raw || raw === "./") return "."
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
return cleaned === "" ? "." : cleaned
}
const getParentPath = (path: string): string | null => {
const current = normalizeBrowserPath(path)
if (current === ".") return null
const parts = current.split("/").filter(Boolean)
parts.pop()
return parts.length ? parts.join("/") : "."
}
const loadBrowserEntries = async (path: string) => {
const normalized = normalizeBrowserPath(path)
setBrowserLoading(true)
setBrowserError(null)
try {
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
setBrowserPath(normalized)
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
} catch (error) {
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
setBrowserEntries([])
} finally {
setBrowserLoading(false)
}
}
const openBrowserFile = async (path: string) => {
setBrowserSelectedPath(path)
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
setBrowserSelectedDirty(false)
setBrowserSelectedOriginalContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setFilesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
const saveBrowserFile = async (content: string): Promise<boolean> => {
const path = browserSelectedPath()
if (!path) return false
// Check for conflict: agent edited file while user was editing
const originalContent = browserSelectedOriginalContent()
if (originalContent !== null) {
try {
const currentDiskContent = await requestData<FileContent>(
browserClient().file.read({ path }),
"file.read",
)
const diskContent = (currentDiskContent as any)?.content
// If disk content differs from what we originally loaded (agent edit)
// AND differs from user's current edits, we have a conflict
if (diskContent !== originalContent && diskContent !== content) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return false
}
// User chose to overwrite, proceed with save
}
} catch {
// If we can't check for conflict, proceed with save
}
}
setBrowserSelectedSaving(true)
try {
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
setBrowserSelectedContent(content)
setBrowserSelectedOriginalContent(content) // Update original to match saved
setBrowserSelectedDirty(false)
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
variant: "success",
})
return true
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveError"),
variant: "error",
})
return false
} finally {
setBrowserSelectedSaving(false)
}
}
const handleBrowserFileChange = (content: string) => {
setBrowserSelectedContent(content)
setBrowserSelectedDirty(true)
}
const handleOpenBrowserFileRequest = async (path: string) => {
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
dismissible: false,
},
)
if (confirmed) {
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
if (!saveSuccess) {
// Save failed - stay on current file, error toast already shown
return
}
} else {
// User chose not to save - clear dirty state and discard edits
setBrowserSelectedDirty(false)
}
}
await openBrowserFile(path)
}
createEffect(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
if (browserEntries() !== null) return
void loadBrowserEntries(browserPath())
})
createEffect(() => {
if (rightPanelTab() === "files") return
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file)
if (closeList) {
setChangesListOpen(false)
}
}
const toggleChangesList = () => {
setChangesListTouched(true)
setChangesListOpen((current) => {
const next = !current
persistListOpen("changes", next)
return next
})
}
const toggleFilesList = () => {
setFilesListTouched(true)
setFilesListOpen((current) => {
const next = !current
persistListOpen("files", next)
return next
})
}
const toggleGitList = () => {
setGitChangesListTouched(true)
setGitChangesListOpen((current) => {
const next = !current
persistListOpen("git-changes", next)
return next
})
}
const refreshFilesTab = async () => {
// Prompt for confirmation if file has unsaved changes
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return
}
}
void loadBrowserEntries(browserPath())
const selected = browserSelectedPath()
if (selected) {
// Refresh file content without altering overlay state.
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
try {
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Update original content after refresh
setBrowserSelectedDirty(false) // Clear dirty after refresh
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
}
const browserParentPath = createMemo(() => getParentPath(browserPath()))
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
const openChangesTabFromStatus = (file?: string) => {
if (file) {
setSelectedFile(file)
}
setRightPanelTab("changes")
}
const statusSectionIds = ["yolo-mode", "session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems())
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
setRightPanelExpandedItems(statusSectionIds)
})
const handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values)
}
const tabClass = (tab: RightPanelTab) =>
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
return (
<div class="flex flex-col h-full" ref={props.setContentEl}>
<div class="right-panel-tab-bar">
<div class="tab-container">
<div class="tab-strip-shortcuts text-primary">
<Show when={props.rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
title={props.t("instanceShell.rightDrawer.toggle.close")}
onClick={props.onCloseRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
>
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
<button
type="button"
role="tab"
class={tabClass("changes")}
aria-selected={rightPanelTab() === "changes"}
onClick={() => setRightPanelTab("changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("git-changes")}
aria-selected={rightPanelTab() === "git-changes"}
onClick={() => setRightPanelTab("git-changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("files")}
aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("files")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("status")}
aria-selected={rightPanelTab() === "status"}
onClick={() => setRightPanelTab("status")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
</button>
</div>
<div class="tab-strip-spacer" />
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "git-changes"}>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyGitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedItemId={gitSelectedItemId}
selectedBulkItemIds={gitBulkSelectedItemIds}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedItemId={gitMostChangedItemId}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onRowClick={handleGitRowClick}
onRefresh={() => void refreshGitStatus()}
onInsertContext={insertGitChangeContext}
onStageFile={stageGitFile}
onUnstageFile={unstageGitFile}
commitMessage={gitCommitMessage}
commitSubmitting={gitCommitSubmitting}
onCommitMessageInput={setGitCommitMessage}
onSubmitCommit={() => void submitGitCommit()}
branchLabel={gitChangesBranchLabel}
stagedOpen={gitStagedOpen}
unstagedOpen={gitUnstagedOpen}
onToggleStagedOpen={() => {
const next = !gitStagedOpen()
setGitStagedOpen(next)
persistGitSectionOpen("staged", next)
}}
onToggleUnstagedOpen={() => {
const next = !gitUnstagedOpen()
setGitUnstagedOpen(next)
persistGitSectionOpen("unstaged", next)
}}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "files"}>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyFilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("files")}
onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "status"}>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyStatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
</Suspense>
</Show>
</div>
</div>
)
}
export default RightPanel