diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
index 64f195471..52a48805c 100644
--- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
@@ -1,17 +1,5 @@
-import {
- For,
- Show,
- Suspense,
- createEffect,
- createMemo,
- createSignal,
- lazy,
- onCleanup,
- type Accessor,
- type Component,
-} from "solid-js"
+import { For, Show, Suspense, createEffect, createMemo, createSignal, createUniqueId, 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 {
DragDropProvider,
DragDropSensors,
@@ -31,61 +19,25 @@ 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 type { RightPanelTab } from "./types"
-import {
- getDefaultWorktreeSlug,
- getGitRepoStatus,
- getWorktreeSlugForSession,
- getWorktrees,
-} from "../../../../stores/worktrees"
-import { getRootClient } from "../../../../stores/opencode-client"
-import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces"
-import { requestData } from "../../../../lib/opencode-api"
-import { serverApi } from "../../../../lib/api-client"
-import { showConfirmDialog } from "../../../../stores/alerts"
-import { showToastNotification } from "../../../../lib/notifications"
import { readClientLayoutValue, writeClientLayoutValue } from "../../../../stores/client-state"
-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_FILES_WORD_WRAP_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_CUSTOMIZATION_STORAGE_KEY,
- RIGHT_PANEL_TAB_STORAGE_KEY,
- readStoredBool,
- readStoredEnum,
- readStoredPanelWidth,
- readStoredRightPanelTab,
-} from "../storage"
+import { RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY, readStoredRightPanelTab } from "../storage"
import {
applyRightPanelItemCustomization,
collectRightPanelItems,
parseRightPanelCustomization,
setRightPanelItemHidden,
type RightPanelCustomization,
- type RightPanelModule,
+ type RightPanelItem,
type RightPanelSectionModule,
type RightPanelTabModule,
} from "./registry"
+import { createCoreRightPanelRuntime } from "./core-runtime"
+import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest"
+import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins"
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
-const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
-const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
-const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
-
function RightPanelTabFallback() {
return
}
@@ -93,9 +45,13 @@ function RightPanelTabFallback() {
interface SortableRightPanelTabProps {
tab: RightPanelTabModule
active: boolean
+ tabId: string
+ panelId: string
label: string
dragTitle: string
+ tabIndex: number
onSelect: () => void
+ onKeyDown: (event: KeyboardEvent) => void
}
const SortableRightPanelTab: Component = (props) => {
@@ -105,10 +61,14 @@ const SortableRightPanelTab: Component = (props) =>
@@ -152,550 +112,14 @@ const RightPanel: Component = (props) => {
const [rightPanelCustomization, setRightPanelCustomization] = createSignal(
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
)
-
- const [browserPath, setBrowserPath] = createSignal(".")
- const [browserEntries, setBrowserEntries] = createSignal(null)
- const [browserLoading, setBrowserLoading] = createSignal(false)
- const [browserError, setBrowserError] = createSignal(null)
- const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null)
- const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null)
- const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
- const [browserSelectedError, setBrowserSelectedError] = createSignal(null)
- const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
- const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
- const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null)
-
- const [diffViewMode, setDiffViewMode] = createSignal(
- readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
- )
- const [diffContextMode, setDiffContextMode] = createSignal(
- readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
- )
- const [diffWordWrapMode, setDiffWordWrapMode] = createSignal(
- readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
- )
- const [filesWordWrapMode, setFilesWordWrapMode] = createSignal(
- readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
- )
-
- const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
- const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
- const [activeSplitResize, setActiveSplitResize] = createSignal<"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 [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: "git-changes" | "files") => {
- const layout = listLayoutKey()
- 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: "git-changes" | "files", value: boolean) => {
- writeClientLayoutValue(listOpenStorageKey(tab), value ? "true" : "false")
- }
-
- const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
- writeClientLayoutValue(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 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)
- }
- })
+ const tabGroupId = `right-panel-${createUniqueId()}`
+ const tabId = (id: string) => `${tabGroupId}-tab-${id}`
+ const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}`
createEffect(() => {
writeClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
})
- createEffect(() => {
- writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
- })
-
- createEffect(() => {
- writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
- })
-
- createEffect(() => {
- writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
- })
-
- createEffect(() => {
- writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())
- })
-
- 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)
- setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
- setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
- })
-
- const persistSplitWidth = (mode: "git-changes" | "files", width: number) => {
- const key = mode === "git-changes" ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
- writeClientLayoutValue(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 === "git-changes") setGitChangesSplitWidth(next)
- else setFilesSplitWidth(next)
- }
-
- function splitMouseUp() {
- const mode = activeSplitResize()
- if (mode) {
- const width = 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 === "git-changes") setGitChangesSplitWidth(next)
- else setFilesSplitWidth(next)
- }
-
- function splitTouchEnd() {
- const mode = activeSplitResize()
- if (mode) {
- const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
- persistSplitWidth(mode, width)
- }
- stopSplitResize()
- }
-
- const splitPointerDrag = useGlobalPointerDrag({
- onMouseMove: splitMouseMove,
- onMouseUp: splitMouseUp,
- onTouchMove: splitTouchMove,
- onTouchEnd: splitTouchEnd,
- })
-
- const startSplitResize = (mode: "git-changes" | "files", clientX: number) => {
- if (typeof document === "undefined") return
- setActiveSplitResize(mode)
- setSplitResizeStartX(clientX)
- setSplitResizeStartWidth(mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth())
- splitPointerDrag.start()
- }
-
- const handleSplitResizeMouseDown = (mode: "git-changes" | "files") => (event: MouseEvent) => {
- event.preventDefault()
- startSplitResize(mode, event.clientX)
- }
-
- const handleSplitResizeTouchStart = (mode: "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(() => getRootClient(props.instanceId))
- const fileWorkspacePayload = async () => {
- const workspace = await getOpenCodeWorkspaceIdForWorktree(props.instanceId, worktreeSlugForViewer())
- return workspace ? { workspace } : {}
- }
-
- 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 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(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "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(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "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 => {
- 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(
- browserClient().file.read({ path, ...(await fileWorkspacePayload()) }),
- "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, { worktree: worktreeSlugForViewer() })
- 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 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(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "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 handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values)
}
@@ -727,129 +151,69 @@ const RightPanel: Component = (props) => {
moveTab(String(draggable.id), String(droppable.id))
}
- const rightPanelModules = createMemo(() => [
+ const openRightPanelTab = (tabId: string) => {
+ updateRightPanelCustomization((current) => ({
+ ...current,
+ hiddenTabIds: current.hiddenTabIds.filter((id) => id !== tabId),
+ }))
+ setRightPanelTab(tabId)
+ }
+
+ const handleTabKeyDown = (event: KeyboardEvent, currentTabId: string) => {
+ const tabs = visibleRightPanelTabs()
+ const index = tabs.findIndex((tab) => tab.id === currentTabId)
+ if (index === -1) return
+
+ let target: RightPanelTabModule | undefined
+ if (event.key === "ArrowLeft") target = tabs[(index - 1 + tabs.length) % tabs.length]
+ if (event.key === "ArrowRight") target = tabs[(index + 1) % tabs.length]
+ if (event.key === "Home") target = tabs[0]
+ if (event.key === "End") target = tabs[tabs.length - 1]
+ if (!target) return
+
+ event.preventDefault()
+ setRightPanelTab(target.id)
+ queueMicrotask(() => document.getElementById(tabId(target.id))?.focus())
+ }
+
+ const rightPanelPluginRuntime = loadRightPanelPluginManifests(
+ [
+ createCoreRightPanelRuntime({
+ t: props.t,
+ instanceId: props.instanceId,
+ instance: props.instance,
+ activeSessionId: props.activeSessionId,
+ activeSession: props.activeSession,
+ latestTodoState: props.latestTodoState,
+ backgroundProcessList: props.backgroundProcessList,
+ onOpenBackgroundOutput: props.onOpenBackgroundOutput,
+ onStopBackgroundProcess: props.onStopBackgroundProcess,
+ onTerminateBackgroundProcess: props.onTerminateBackgroundProcess,
+ isPhoneLayout: props.isPhoneLayout,
+ rightDrawerWidth: props.rightDrawerWidth,
+ rightDrawerWidthInitialized: props.rightDrawerWidthInitialized,
+ promptInputApi: props.promptInputApi,
+ rightPanelTab,
+ expandedItems: rightPanelExpandedItems,
+ onExpandedItemsChange: handleAccordionChange,
+ customization: rightPanelCustomization,
+ onCustomizationChange: updateRightPanelCustomization,
+ extraStatusSections: () => extraStatusSections(),
+ }),
+ ...RIGHT_PANEL_PLUGIN_MANIFESTS,
+ ],
{
- id: "core-right-panel",
- tabs: [
- {
- id: "git-changes",
- labelKey: "instanceShell.rightPanel.tabs.gitChanges",
- order: 10,
- render: () => (
- 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}
- />
- ),
- },
- {
- id: "files",
- labelKey: "instanceShell.rightPanel.tabs.files",
- order: 20,
- render: () => (
- void loadBrowserEntries(path)}
- onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
- onRefresh={() => void refreshFilesTab()}
- onSave={(content: string) => void saveBrowserFile(content)}
- onContentChange={(content: string) => handleBrowserFileChange(content)}
- onWordWrapModeChange={setFilesWordWrapMode}
- listOpen={filesListOpen}
- onToggleList={toggleFilesList}
- splitWidth={filesSplitWidth}
- onResizeMouseDown={handleSplitResizeMouseDown("files")}
- onResizeTouchStart={handleSplitResizeTouchStart("files")}
- isPhoneLayout={props.isPhoneLayout}
- />
- ),
- },
- {
- id: "status",
- labelKey: "instanceShell.rightPanel.tabs.status",
- order: 30,
- render: () => (
-
- ),
- },
- ],
+ instanceId: props.instanceId,
+ t: props.t,
+ activeSessionId: props.activeSessionId,
+ isTabActive: (tabId) => rightPanelTab() === tabId,
+ openTab: openRightPanelTab,
+ reportAttention: () => undefined,
},
- ])
+ )
+ const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules)
+ const rightPanelPluginErrors = createMemo(() => rightPanelPluginRuntime.errors)
const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs"))
const visibleRightPanelTabs = createMemo(() =>
applyRightPanelItemCustomization(
@@ -860,7 +224,7 @@ const RightPanel: Component = (props) => {
)
const orderedRightPanelTabs = createMemo(() => applyRightPanelItemCustomization(allRightPanelTabs(), rightPanelCustomization().tabOrder, []))
const extraStatusSections = createMemo(() => collectRightPanelItems(rightPanelModules(), "statusSections"))
- const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()])
+ const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()])
const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), rightPanelCustomization().statusSectionOrder, []))
const visibleStatusSections = createMemo(() =>
applyRightPanelItemCustomization(
@@ -926,9 +290,13 @@ const RightPanel: Component = (props) => {
setRightPanelTab(tab.id)}
+ onKeyDown={(event) => handleTabKeyDown(event, tab.id)}
/>
)}
@@ -945,35 +313,19 @@ const RightPanel: Component = (props) => {
-
-
-
-
{props.t("instanceShell.rightPanel.customize.tabs")}
-
- {(tab) => {
- const label = () => props.t(tab.labelKey)
- const visible = () => !rightPanelCustomization().hiddenTabIds.includes(tab.id)
- const disableHide = () => visible() && visibleRightPanelTabs().length <= 1
- return (
+
+ {(tab) => {
+ const label = () => props.t(tab.labelKey)
+ const visible = () => tab.alwaysVisible || !rightPanelCustomization().hiddenTabIds.includes(tab.id)
+ return (
+ <>
- )
- }}
-
-
-
-
-
{props.t("instanceShell.rightPanel.customize.statusSections")}
-
- {(section) => {
- const label = () => props.t(section.labelKey)
- const visible = () => !rightPanelCustomization().hiddenStatusSectionIds.includes(section.id)
- const disableHide = () => visible() && visibleStatusSections().length <= 1
- return (
-
-
-
- )
- }}
+
+
+ {(section) => {
+ const sectionLabel = () => props.t(section.labelKey)
+ const sectionVisible = () => !rightPanelCustomization().hiddenStatusSectionIds.includes(section.id)
+ const disableHide = () => sectionVisible() && visibleStatusSections().length <= 1
+ return (
+
+
+
+ )
+ }}
+
+
+ >
+ )
+ }}
+
+
+
0}>
+
+ {(error: RightPanelPluginLoadError) => (
+
+
+ {props.t("instanceShell.rightPanel.customize.moduleUnavailable", {
+ module: error.displayNameKey ? props.t(error.displayNameKey) : error.pluginId,
+ })}
+
+
+ )}
-
+
-
{props.t("instanceShell.rightPanel.customize.dragToReorder")}
+
- {(tab) => }>{tab.render()}}
+ {(tab) => (
+
+ }>{tab.render()}
+
+ )}
diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx
new file mode 100644
index 000000000..20fd18c77
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx
@@ -0,0 +1,81 @@
+import type { JSX } from "solid-js"
+
+import type { RightPanelManifest } from "./plugin-manifest"
+import type { RightPanelModule } from "./registry"
+import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
+
+interface CoreRightPanelRenderers {
+ renderGitChangesTab: () => JSX.Element
+ renderFilesTab: () => JSX.Element
+ renderStatusTab: () => JSX.Element
+}
+
+interface CoreStatusSectionRenderers {
+ renderYoloModeSection: () => JSX.Element
+ renderProviderUsage: () => JSX.Element
+ renderPlanSectionContent: () => JSX.Element
+ renderBackgroundProcesses: () => JSX.Element
+ renderMcpStatus: () => JSX.Element
+ renderLspStatus: () => JSX.Element
+ renderPluginStatus: () => JSX.Element
+}
+
+export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelManifest {
+ return {
+ id: "core-right-panel",
+ displayNameKey: "instanceShell.rightPanel.modules.core",
+ descriptionKey: "instanceShell.rightPanel.modules.core.description",
+ origin: "first-party",
+ create: () => ({
+ id: "core-right-panel",
+ displayNameKey: "instanceShell.rightPanel.modules.core",
+ descriptionKey: "instanceShell.rightPanel.modules.core.description",
+ origin: "first-party",
+ tabs: [
+ {
+ id: "git-changes",
+ labelKey: "instanceShell.rightPanel.tabs.gitChanges",
+ order: 10,
+ render: renderers.renderGitChangesTab,
+ },
+ {
+ id: "files",
+ labelKey: "instanceShell.rightPanel.tabs.files",
+ order: 20,
+ render: renderers.renderFilesTab,
+ },
+ {
+ id: "status",
+ labelKey: "instanceShell.rightPanel.tabs.status",
+ order: 30,
+ alwaysVisible: true,
+ render: renderers.renderStatusTab,
+ },
+ ],
+ }),
+ }
+}
+
+export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelModule {
+ const sectionRenderers: Record JSX.Element> = {
+ "yolo-mode": renderers.renderYoloModeSection,
+ "provider-usage": renderers.renderProviderUsage,
+ plan: renderers.renderPlanSectionContent,
+ "background-processes": renderers.renderBackgroundProcesses,
+ mcp: renderers.renderMcpStatus,
+ lsp: renderers.renderLspStatus,
+ plugins: renderers.renderPluginStatus,
+ }
+
+ return {
+ id: "core-status-sections",
+ displayNameKey: "instanceShell.rightPanel.modules.core",
+ descriptionKey: "instanceShell.rightPanel.modules.core.description",
+ origin: "first-party",
+ statusSections: CORE_STATUS_SECTION_ITEMS.map((section) => {
+ const render = sectionRenderers[section.id]
+ if (!render) throw new Error(`Missing core right panel section renderer: ${section.id}`)
+ return { ...section, render }
+ }),
+ }
+}
diff --git a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx
new file mode 100644
index 000000000..c7be8ecce
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx
@@ -0,0 +1,245 @@
+import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js"
+import type { ToolState } from "@opencode-ai/sdk/v2"
+
+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 { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
+import type { RightPanelCustomization, RightPanelSectionModule } from "./registry"
+
+import {
+ getDefaultWorktreeSlug,
+ getGitRepoStatus,
+ getWorktreeSlugForSession,
+ getWorktrees,
+} from "../../../../stores/worktrees"
+import { writeClientLayoutValue } from "../../../../stores/client-state"
+import {
+ RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
+ RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
+ RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
+ RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
+ RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
+ RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
+ RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
+ RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
+ RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
+ RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
+ readStoredBool,
+ readStoredEnum,
+} from "../storage"
+import { useGitChanges } from "./useGitChanges"
+import { createCoreRightPanelManifest } from "./core-plugin"
+import { createFilesTabRuntime } from "./tabs/files-runtime"
+import { createSplitResize } from "./tabs/split-resize"
+
+const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
+const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
+
+interface CoreRightPanelRuntimeOptions {
+ t: (key: string, vars?: Record) => string
+ instanceId: string
+ instance: Instance
+ activeSessionId: Accessor
+ activeSession: Accessor
+ latestTodoState: Accessor
+ backgroundProcessList: Accessor
+ onOpenBackgroundOutput: (process: BackgroundProcess) => void
+ onStopBackgroundProcess: (processId: string) => Promise | void
+ onTerminateBackgroundProcess: (processId: string) => Promise | void
+ isPhoneLayout: Accessor
+ rightDrawerWidth: Accessor
+ rightDrawerWidthInitialized: Accessor
+ promptInputApi: Accessor
+ rightPanelTab: Accessor
+ expandedItems: Accessor
+ onExpandedItemsChange: (values: string[]) => void
+ customization: Accessor
+ onCustomizationChange: (updater: (current: RightPanelCustomization) => RightPanelCustomization) => void
+ extraStatusSections: Accessor
+}
+
+export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOptions) {
+ const [diffViewMode, setDiffViewMode] = createSignal(
+ readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
+ )
+ const [diffContextMode, setDiffContextMode] = createSignal(
+ readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
+ )
+ const [diffWordWrapMode, setDiffWordWrapMode] = createSignal(
+ readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
+ )
+ const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
+ const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
+ const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
+
+ const listLayoutKey = createMemo(() => (options.isPhoneLayout() ? "phone" : "nonphone"))
+
+ const gitListOpenStorageKey = createMemo(() =>
+ listLayoutKey() === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
+ )
+
+ const gitSectionStorageKey = (section: "staged" | "unstaged") => {
+ const phone = listLayoutKey() === "phone"
+ if (section === "staged") {
+ return phone ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
+ }
+ return phone ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
+ }
+
+ createEffect(() => {
+ gitListOpenStorageKey()
+ const gitPersisted = readStoredBool(gitListOpenStorageKey())
+ if (gitPersisted !== null) {
+ setGitChangesListOpen(gitPersisted)
+ } else {
+ setGitChangesListOpen(true)
+ }
+
+ setGitStagedOpen(readStoredBool(gitSectionStorageKey("staged")) ?? true)
+ setGitUnstagedOpen(readStoredBool(gitSectionStorageKey("unstaged")) ?? true)
+ })
+
+ createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()))
+ createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()))
+ createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode()))
+
+ const gitChangesSplit = createSplitResize({
+ storageKey: RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
+ defaultWidth: 320,
+ rightDrawerWidth: options.rightDrawerWidth,
+ rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
+ })
+
+ const worktreeSlugForViewer = createMemo(() => {
+ const sessionId = options.activeSessionId()
+ if (sessionId && sessionId !== "info") {
+ return getWorktreeSlugForSession(options.instanceId, sessionId)
+ }
+ return getDefaultWorktreeSlug(options.instanceId)
+ })
+
+ const gitChangesWorktreeSlug = createMemo(() => {
+ if (getGitRepoStatus(options.instanceId) === false) return null
+ const slug = worktreeSlugForViewer().trim()
+ return slug ? slug : null
+ })
+
+ const gitChangesWorktree = createMemo(() => {
+ const slug = gitChangesWorktreeSlug()
+ if (!slug) return null
+ return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null
+ })
+
+ const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null)
+ const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`)
+ const git = useGitChanges({
+ t: options.t,
+ instanceId: options.instanceId,
+ rightPanelTab: options.rightPanelTab,
+ worktreeSlug: worktreeSlugForViewer,
+ isPhoneLayout: options.isPhoneLayout,
+ promptInputApi: options.promptInputApi,
+ closeGitList: () => setGitChangesListOpen(false),
+ })
+ const renderFilesTab = createFilesTabRuntime({
+ t: options.t,
+ instanceId: options.instanceId,
+ rightPanelTab: options.rightPanelTab,
+ worktreeSlug: worktreeSlugForViewer,
+ isPhoneLayout: options.isPhoneLayout,
+ rightDrawerWidth: options.rightDrawerWidth,
+ rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
+ })
+
+ const persistGitListOpen = (value: boolean) => {
+ writeClientLayoutValue(gitListOpenStorageKey(), value ? "true" : "false")
+ }
+
+ const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
+ writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false")
+ }
+
+ const toggleGitList = () => {
+ setGitChangesListOpen((current) => {
+ const next = !current
+ persistGitListOpen(next)
+ return next
+ })
+ }
+
+ return createCoreRightPanelManifest({
+ renderGitChangesTab: () => (
+ void git.refreshGitStatus()}
+ onInsertContext={git.insertGitChangeContext}
+ onStageFile={git.stageGitFile}
+ onUnstageFile={git.unstageGitFile}
+ commitMessage={git.gitCommitMessage}
+ commitSubmitting={git.gitCommitSubmitting}
+ onCommitMessageInput={git.setGitCommitMessage}
+ onSubmitCommit={() => void git.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={gitChangesSplit.splitWidth}
+ onResizeMouseDown={gitChangesSplit.onResizeMouseDown}
+ onResizeTouchStart={gitChangesSplit.onResizeTouchStart}
+ isPhoneLayout={options.isPhoneLayout}
+ />
+ ),
+ renderFilesTab,
+ renderStatusTab: () => (
+
+ ),
+ })
+}
diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts
new file mode 100644
index 000000000..7aa0f7f58
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts
@@ -0,0 +1,93 @@
+import assert from "node:assert/strict"
+import { describe, it } from "node:test"
+
+import { createCoreRightPanelManifest, createCoreStatusSectionManifest } from "./core-plugin"
+import { loadRightPanelPluginManifests, type RightPanelHostContext, type RightPanelManifest } from "./plugin-manifest"
+
+const host: RightPanelHostContext = {
+ instanceId: "abc",
+ t: (key) => key,
+ activeSessionId: () => "session-1",
+ isTabActive: () => false,
+ openTab: () => {},
+}
+
+const manifest = (id: string, events: string[]): RightPanelManifest => ({
+ id,
+ displayNameKey: id,
+ origin: "first-party",
+ create: (context) => {
+ events.push(`${id}:create:${context.instanceId}:${context.activeSessionId()}`)
+ return {
+ id,
+ displayNameKey: id,
+ origin: "first-party",
+ tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }],
+ }
+ },
+})
+
+describe("right panel plugin manifests", () => {
+ it("creates modules with host context", () => {
+ const events: string[] = []
+ const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], host)
+
+ assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"])
+ assert.deepEqual(events, ["first:create:abc:session-1", "second:create:abc:session-1"])
+ })
+
+ it("skips duplicate ids without blocking other plugins", () => {
+ const events: string[] = []
+ const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], host)
+
+ assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"])
+ assert.equal(runtime.errors.length, 1)
+ assert.equal(runtime.errors[0]?.pluginId, "plugin")
+ })
+
+ it("skips plugins that fail during load", () => {
+ const runtime = loadRightPanelPluginManifests(
+ [
+ { id: "bad", displayNameKey: "bad", origin: "first-party", create: () => { throw new Error("boom") } },
+ manifest("good", []),
+ ],
+ host,
+ )
+
+ assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"])
+ assert.equal(runtime.errors.length, 1)
+ assert.equal(runtime.errors[0]?.pluginId, "bad")
+ })
+
+ it("defines core right panel tabs and status sections as manifests", () => {
+ const render = () => undefined as any
+ const rightPanel = createCoreRightPanelManifest({
+ renderGitChangesTab: render,
+ renderFilesTab: render,
+ renderStatusTab: render,
+ })
+ const statusSections = createCoreStatusSectionManifest({
+ renderYoloModeSection: render,
+ renderProviderUsage: render,
+ renderPlanSectionContent: render,
+ renderBackgroundProcesses: render,
+ renderMcpStatus: render,
+ renderLspStatus: render,
+ renderPluginStatus: render,
+ })
+
+ const rightPanelModule = rightPanel.create(host)
+
+ assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"])
+ assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true)
+ assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [
+ "yolo-mode",
+ "provider-usage",
+ "plan",
+ "background-processes",
+ "mcp",
+ "lsp",
+ "plugins",
+ ])
+ })
+})
diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts
new file mode 100644
index 000000000..b5337f94d
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts
@@ -0,0 +1,75 @@
+import type { Accessor } from "solid-js"
+import type { RightPanelModule } from "./registry"
+
+export interface RightPanelHostContext {
+ instanceId: string
+ t: (key: string, vars?: Record) => string
+ activeSessionId: Accessor
+ isTabActive: (tabId: string) => boolean
+ openTab: (tabId: string) => void
+ reportAttention?: (attention: RightPanelAttention) => void
+}
+
+export interface RightPanelAttention {
+ moduleId: string
+ tabId?: string
+ messageKey: string
+ severity: "info" | "warning" | "critical"
+}
+
+export interface RightPanelManifest {
+ id: string
+ displayNameKey: string
+ descriptionKey?: string
+ origin: "first-party"
+ create: (host: RightPanelHostContext) => RightPanelModule
+}
+
+export interface RightPanelPluginLoadError {
+ pluginId: string
+ displayNameKey?: string
+ phase: "create"
+ error: unknown
+}
+
+export interface LoadedRightPanelPlugins {
+ modules: RightPanelModule[]
+ errors: RightPanelPluginLoadError[]
+}
+
+export function loadRightPanelPluginManifests(
+ manifests: readonly RightPanelManifest[],
+ context: RightPanelHostContext,
+): LoadedRightPanelPlugins {
+ const modules: RightPanelModule[] = []
+ const errors: RightPanelPluginLoadError[] = []
+ const seen = new Set()
+
+ for (const manifest of manifests) {
+ if (!manifest.id || seen.has(manifest.id)) {
+ errors.push({
+ pluginId: manifest.id || "",
+ displayNameKey: manifest.displayNameKey,
+ phase: "create",
+ error: new Error("Duplicate or missing right panel plugin id"),
+ })
+ continue
+ }
+ seen.add(manifest.id)
+
+ try {
+ const module = manifest.create(context)
+ modules.push({
+ ...module,
+ id: manifest.id,
+ displayNameKey: manifest.displayNameKey,
+ descriptionKey: manifest.descriptionKey ?? module.descriptionKey,
+ origin: manifest.origin,
+ })
+ } catch (error) {
+ errors.push({ pluginId: manifest.id, displayNameKey: manifest.displayNameKey, phase: "create", error })
+ }
+ }
+
+ return { modules, errors }
+}
diff --git a/packages/ui/src/components/instance/shell/right-panel/plugins.ts b/packages/ui/src/components/instance/shell/right-panel/plugins.ts
new file mode 100644
index 000000000..372530ecf
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/plugins.ts
@@ -0,0 +1,3 @@
+import type { RightPanelManifest } from "./plugin-manifest"
+
+export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelManifest[] = []
diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts
index a7b39d015..8575b053d 100644
--- a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts
+++ b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts
@@ -8,18 +8,20 @@ import {
parseRightPanelCustomization,
setRightPanelItemHidden,
type RightPanelItem,
+ type RightPanelModule,
type RightPanelTabModule,
} from "./registry"
const item = (id: string, order: number, alwaysVisible = false): RightPanelItem => ({ id, labelKey: id, order, alwaysVisible })
const tab = (id: string, order: number): RightPanelTabModule => ({ ...item(id, order), render: () => undefined as any })
+const module = (id: string, tabs: RightPanelTabModule[]): RightPanelModule => ({ id, displayNameKey: id, origin: "first-party", tabs })
describe("right panel registry", () => {
it("collects and orders module items", () => {
const items = collectRightPanelItems(
[
- { id: "core", tabs: [tab("status", 40), tab("changes", 10)] },
- { id: "plugin", tabs: [tab("custom", 30)] },
+ module("core", [tab("status", 40), tab("changes", 10)]),
+ module("plugin", [tab("custom", 30)]),
],
"tabs",
)
@@ -28,7 +30,7 @@ describe("right panel registry", () => {
})
it("rejects duplicate item ids", () => {
- assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs"))
+ assert.throws(() => collectRightPanelItems([module("core", [tab("status", 10), tab("status", 20)])], "tabs"))
})
it("applies visibility and user order", () => {
diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.ts b/packages/ui/src/components/instance/shell/right-panel/registry.ts
index c24077eac..47aba6356 100644
--- a/packages/ui/src/components/instance/shell/right-panel/registry.ts
+++ b/packages/ui/src/components/instance/shell/right-panel/registry.ts
@@ -19,6 +19,9 @@ export interface RightPanelSectionModule extends RightPanelItem {
export interface RightPanelModule {
id: string
+ displayNameKey: string
+ descriptionKey?: string
+ origin: "first-party"
tabs?: readonly RightPanelTabModule[]
statusSections?: readonly RightPanelSectionModule[]
}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
index 84baefd15..33d1b55d0 100644
--- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx
@@ -25,6 +25,7 @@ import InstanceServiceStatus from "../../../../instance-service-status"
import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances"
import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept"
import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry"
+import { createCoreStatusSectionManifest } from "../core-plugin"
interface StatusTabProps {
t: (key: string, vars?: Record) => string
@@ -227,66 +228,28 @@ const StatusTab: Component = (props) => {
)
}
- const statusSections = createMemo(() => {
- const sections: RightPanelSectionModule[] = [
- {
- id: "yolo-mode",
- labelKey: "instanceShell.rightPanel.sections.yoloMode",
- tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip",
- order: 10,
- render: renderYoloModeSection,
- },
- {
- id: "provider-usage",
- labelKey: "providerUsage.title",
- tooltipKey: "providerUsage.tooltip",
- order: 20,
- render: renderProviderUsage,
- },
- {
- id: "plan",
- labelKey: "instanceShell.rightPanel.sections.plan",
- tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
- order: 30,
- render: renderPlanSectionContent,
- },
- {
- id: "background-processes",
- labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
- tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
- order: 40,
- render: renderBackgroundProcesses,
- },
- {
- id: "mcp",
- labelKey: "instanceShell.rightPanel.sections.mcp",
- tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
- order: 50,
- render: () => ,
- },
- {
- id: "lsp",
- labelKey: "instanceShell.rightPanel.sections.lsp",
- tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
- order: 60,
- render: () => ,
- },
- {
- id: "plugins",
- labelKey: "instanceShell.rightPanel.sections.plugins",
- tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
- order: 70,
- render: () => (
-
- ),
- },
- ]
- return applyRightPanelItemCustomization(
- [...sections, ...(props.extraSections ?? [])],
+ const allStatusSections = createMemo(() => {
+ const sections = createCoreStatusSectionManifest({
+ renderYoloModeSection,
+ renderProviderUsage,
+ renderPlanSectionContent,
+ renderBackgroundProcesses,
+ renderMcpStatus: () => ,
+ renderLspStatus: () => ,
+ renderPluginStatus: () => (
+
+ ),
+ }).statusSections ?? []
+
+ return [...sections, ...(props.extraSections ?? [])]
+ })
+ const statusSections = createMemo(() =>
+ applyRightPanelItemCustomization(
+ allStatusSections(),
props.customization().statusSectionOrder,
props.customization().hiddenStatusSectionIds,
- )
- })
+ ),
+ )
const moveSection = (sourceId: string, targetId: string) => {
if (!sourceId || sourceId === targetId) return
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx
new file mode 100644
index 000000000..462b20a1e
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx
@@ -0,0 +1,316 @@
+import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js"
+import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
+
+import type { DiffWordWrapMode, RightPanelTab } from "../types"
+
+import { getRootClient } from "../../../../../stores/opencode-client"
+import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces"
+import { requestData } from "../../../../../lib/opencode-api"
+import { serverApi } from "../../../../../lib/api-client"
+import { showConfirmDialog } from "../../../../../stores/alerts"
+import { showToastNotification } from "../../../../../lib/notifications"
+import { writeClientLayoutValue } from "../../../../../stores/client-state"
+import {
+ RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
+ RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
+ RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
+ RIGHT_PANEL_FILES_WORD_WRAP_KEY,
+ readStoredBool,
+ readStoredEnum,
+} from "../../storage"
+import { createSplitResize } from "./split-resize"
+
+const LazyFilesTab = lazy(() => import("./FilesTab"))
+
+interface FilesTabRuntimeOptions {
+ t: (key: string, vars?: Record) => string
+ instanceId: string
+ rightPanelTab: Accessor
+ worktreeSlug: Accessor
+ isPhoneLayout: Accessor
+ rightDrawerWidth: Accessor
+ rightDrawerWidthInitialized: Accessor
+}
+
+export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JSX.Element {
+ const [browserPath, setBrowserPath] = createSignal(".")
+ const [browserEntries, setBrowserEntries] = createSignal(null)
+ const [browserLoading, setBrowserLoading] = createSignal(false)
+ const [browserError, setBrowserError] = createSignal(null)
+ const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null)
+ const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null)
+ const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
+ const [browserSelectedError, setBrowserSelectedError] = createSignal(null)
+ const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
+ const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
+ const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null)
+ const [filesWordWrapMode, setFilesWordWrapMode] = createSignal(
+ readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off",
+ )
+ const [filesListOpen, setFilesListOpen] = createSignal(true)
+ const [filesListTouched, setFilesListTouched] = createSignal(false)
+ const browserClient = createMemo(() => getRootClient(options.instanceId))
+ const filesSplit = createSplitResize({
+ storageKey: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
+ defaultWidth: 320,
+ rightDrawerWidth: options.rightDrawerWidth,
+ rightDrawerWidthInitialized: options.rightDrawerWidthInitialized,
+ })
+
+ const filesListOpenStorageKey = createMemo(() =>
+ options.isPhoneLayout() ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
+ )
+
+ const fileWorkspacePayload = async () => {
+ const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug())
+ return workspace ? { workspace } : {}
+ }
+
+ createEffect(() => {
+ filesListOpenStorageKey()
+ const persisted = readStoredBool(filesListOpenStorageKey())
+ if (persisted !== null) {
+ setFilesListOpen(persisted)
+ setFilesListTouched(true)
+ } else {
+ setFilesListOpen(true)
+ setFilesListTouched(false)
+ }
+ })
+
+ createEffect(() => {
+ if (options.rightPanelTab() !== "files") return
+ if (filesListTouched()) return
+ if (!browserSelectedPath()) setFilesListOpen(true)
+ })
+
+ createEffect(() => writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode()))
+
+ createEffect(() => {
+ options.worktreeSlug()
+ setBrowserPath(".")
+ setBrowserEntries(null)
+ setBrowserError(null)
+ setBrowserSelectedPath(null)
+ setBrowserSelectedContent(null)
+ setBrowserSelectedError(null)
+ setBrowserSelectedLoading(false)
+ })
+
+ 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(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "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)
+
+ if (options.isPhoneLayout()) setFilesListOpen(false)
+ try {
+ const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "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)
+ } catch (error) {
+ setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
+ } finally {
+ setBrowserSelectedLoading(false)
+ }
+ }
+
+ const saveBrowserFile = async (content: string): Promise => {
+ const path = browserSelectedPath()
+ if (!path) return false
+
+ const originalContent = browserSelectedOriginalContent()
+ if (originalContent !== null) {
+ try {
+ const currentDiskContent = await requestData(
+ browserClient().file.read({ path, ...(await fileWorkspacePayload()) }),
+ "file.read",
+ )
+ const diskContent = (currentDiskContent as any)?.content
+ if (diskContent !== originalContent && diskContent !== content) {
+ const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path }), {
+ variant: "warning",
+ confirmLabel: options.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
+ cancelLabel: options.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
+ dismissible: false,
+ })
+ if (!confirmed) return false
+ }
+ } catch {
+ // If conflict detection fails, keep the existing behavior and try the save.
+ }
+ }
+
+ setBrowserSelectedSaving(true)
+ try {
+ await serverApi.writeWorkspaceFile(options.instanceId, path, content, { worktree: options.worktreeSlug() })
+ setBrowserSelectedContent(content)
+ setBrowserSelectedOriginalContent(content)
+ setBrowserSelectedDirty(false)
+ showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveSuccess"), variant: "success" })
+ return true
+ } catch (error) {
+ setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
+ showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveError"), variant: "error" })
+ return false
+ } finally {
+ setBrowserSelectedSaving(false)
+ }
+ }
+
+ const handleOpenBrowserFileRequest = async (path: string) => {
+ if (browserSelectedDirty()) {
+ const confirmed = await showConfirmDialog(
+ options.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
+ {
+ variant: "warning",
+ confirmLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
+ cancelLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
+ dismissible: false,
+ },
+ )
+ if (confirmed) {
+ const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
+ if (!saveSuccess) return
+ } else {
+ setBrowserSelectedDirty(false)
+ }
+ }
+ await openBrowserFile(path)
+ }
+
+ createEffect(() => {
+ if (options.rightPanelTab() !== "files") return
+ if (browserLoading()) return
+ if (browserEntries() !== null) return
+ void loadBrowserEntries(browserPath())
+ })
+
+ createEffect(() => {
+ if (options.rightPanelTab() === "files") return
+ setBrowserSelectedContent(null)
+ setBrowserSelectedLoading(false)
+ setBrowserSelectedError(null)
+ setBrowserSelectedDirty(false)
+ })
+
+ const toggleFilesList = () => {
+ setFilesListTouched(true)
+ setFilesListOpen((current) => {
+ const next = !current
+ writeClientLayoutValue(filesListOpenStorageKey(), next ? "true" : "false")
+ return next
+ })
+ }
+
+ const refreshFilesTab = async () => {
+ if (browserSelectedDirty()) {
+ const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.refreshDirty.message"), {
+ variant: "warning",
+ confirmLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
+ cancelLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
+ dismissible: false,
+ })
+ if (!confirmed) return
+ }
+
+ void loadBrowserEntries(browserPath())
+ const selected = browserSelectedPath()
+ if (!selected) return
+
+ setBrowserSelectedLoading(true)
+ setBrowserSelectedError(null)
+ try {
+ const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "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)
+ setBrowserSelectedDirty(false)
+ } catch (error) {
+ setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
+ } finally {
+ setBrowserSelectedLoading(false)
+ }
+ }
+
+ const browserParentPath = createMemo(() => getParentPath(browserPath()))
+ const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`)
+
+ return () => (
+ void loadBrowserEntries(path)}
+ onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
+ onRefresh={() => void refreshFilesTab()}
+ onSave={(content: string) => void saveBrowserFile(content)}
+ onContentChange={(content: string) => {
+ setBrowserSelectedContent(content)
+ setBrowserSelectedDirty(true)
+ }}
+ onWordWrapModeChange={setFilesWordWrapMode}
+ listOpen={filesListOpen}
+ onToggleList={toggleFilesList}
+ splitWidth={filesSplit.splitWidth}
+ onResizeMouseDown={filesSplit.onResizeMouseDown}
+ onResizeTouchStart={filesSplit.onResizeTouchStart}
+ isPhoneLayout={options.isPhoneLayout}
+ />
+ )
+}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts
new file mode 100644
index 000000000..8f3a8cad7
--- /dev/null
+++ b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts
@@ -0,0 +1,98 @@
+import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
+
+import { writeClientLayoutValue } from "../../../../../stores/client-state"
+import { readStoredPanelWidth } from "../../storage"
+import { useGlobalPointerDrag } from "../../useGlobalPointerDrag"
+
+interface SplitResizeOptions {
+ storageKey: string
+ defaultWidth: number
+ rightDrawerWidth: Accessor
+ rightDrawerWidthInitialized: Accessor
+}
+
+export function createSplitResize(options: SplitResizeOptions) {
+ const [splitWidth, setSplitWidth] = createSignal(options.defaultWidth)
+ const [initialized, setInitialized] = createSignal(false)
+ const [active, setActive] = createSignal(false)
+ const [startX, setStartX] = createSignal(0)
+ const [startWidth, setStartWidth] = createSignal(0)
+
+ const clampSplitWidth = (value: number) => {
+ const min = 200
+ const maxByDrawer = Math.max(min, Math.floor(options.rightDrawerWidth() * 0.65))
+ const max = Math.min(560, maxByDrawer)
+ return Math.min(max, Math.max(min, Math.floor(value)))
+ }
+
+ createEffect(() => {
+ if (initialized()) return
+ if (!options.rightDrawerWidthInitialized()) return
+ setInitialized(true)
+ setSplitWidth(clampSplitWidth(readStoredPanelWidth(options.storageKey, options.defaultWidth)))
+ })
+
+ const persistSplitWidth = () => {
+ writeClientLayoutValue(options.storageKey, String(splitWidth()))
+ }
+
+ function stopResize() {
+ setActive(false)
+ if (typeof document === "undefined") return
+ pointerDrag.stop()
+ }
+
+ function move(clientX: number) {
+ if (!active()) return
+ const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
+ const delta = (clientX - startX()) * (isRtl ? -1 : 1)
+ setSplitWidth(clampSplitWidth(startWidth() + delta))
+ }
+
+ const pointerDrag = useGlobalPointerDrag({
+ onMouseMove: (event) => {
+ if (!active()) return
+ event.preventDefault()
+ move(event.clientX)
+ },
+ onMouseUp: () => {
+ if (active()) persistSplitWidth()
+ stopResize()
+ },
+ onTouchMove: (event) => {
+ if (!active()) return
+ const touch = event.touches[0]
+ if (!touch) return
+ event.preventDefault()
+ move(touch.clientX)
+ },
+ onTouchEnd: () => {
+ if (active()) persistSplitWidth()
+ stopResize()
+ },
+ })
+
+ const startResize = (clientX: number) => {
+ if (typeof document === "undefined") return
+ setActive(true)
+ setStartX(clientX)
+ setStartWidth(splitWidth())
+ pointerDrag.start()
+ }
+
+ onCleanup(stopResize)
+
+ return {
+ splitWidth,
+ onResizeMouseDown: (event: MouseEvent) => {
+ event.preventDefault()
+ startResize(event.clientX)
+ },
+ onResizeTouchStart: (event: TouchEvent) => {
+ const touch = event.touches[0]
+ if (!touch) return
+ event.preventDefault()
+ startResize(touch.clientX)
+ },
+ }
+}
diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts
index 5b5e9018c..d3eaa2635 100644
--- a/packages/ui/src/lib/i18n/messages/de/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/de/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Dateien",
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Tabs im rechten Panel",
+ "instanceShell.rightPanel.modules.core": "CodeNomad-Kern",
+ "instanceShell.rightPanel.modules.core.description": "Integrierte Git-, Dateien- und Status-Panels.",
"instanceShell.rightPanel.customize.toggle": "Rechtes Panel anpassen",
"instanceShell.rightPanel.customize.title": "Rechtes Panel anpassen",
- "instanceShell.rightPanel.customize.description": "Tabs und Statusabschnitte anzeigen oder ausblenden.",
+ "instanceShell.rightPanel.customize.description": "Module des rechten Panels anzeigen oder ausblenden.",
"instanceShell.rightPanel.customize.reset": "Zurücksetzen",
"instanceShell.rightPanel.customize.tabs": "Tabs",
+ "instanceShell.rightPanel.customize.sections": "Abschnitte anpassen",
"instanceShell.rightPanel.customize.statusSections": "Statusabschnitte",
- "instanceShell.rightPanel.customize.dragToReorder": "Tabs oder Statusabschnitte ziehen, um sie neu anzuordnen.",
+ "instanceShell.rightPanel.customize.dragToReorder": "Ziehen zum Neuordnen.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "Immer sichtbar",
+ "instanceShell.rightPanel.customize.unavailableModules": "Nicht verfügbare Module",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} nicht verfügbar",
"instanceShell.rightPanel.customize.moveUp": "Nach oben",
"instanceShell.rightPanel.customize.moveDown": "Nach unten",
"instanceShell.rightPanel.customize.moveTabUp": "Tab {label} nach oben verschieben",
diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts
index f3371780b..bd4fb6990 100644
--- a/packages/ui/src/lib/i18n/messages/en/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/en/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Files",
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
+ "instanceShell.rightPanel.modules.core": "CodeNomad core",
+ "instanceShell.rightPanel.modules.core.description": "Built-in Git, Files, and Status panels.",
"instanceShell.rightPanel.customize.toggle": "Customize right panel",
"instanceShell.rightPanel.customize.title": "Customize right panel",
- "instanceShell.rightPanel.customize.description": "Show or hide tabs and status sections.",
+ "instanceShell.rightPanel.customize.description": "Show or hide right panel modules.",
"instanceShell.rightPanel.customize.reset": "Reset",
"instanceShell.rightPanel.customize.tabs": "Tabs",
+ "instanceShell.rightPanel.customize.sections": "Customize sections",
"instanceShell.rightPanel.customize.statusSections": "Status sections",
- "instanceShell.rightPanel.customize.dragToReorder": "Drag tabs or status sections to reorder.",
+ "instanceShell.rightPanel.customize.dragToReorder": "Drag to reorder.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "Always visible",
+ "instanceShell.rightPanel.customize.unavailableModules": "Unavailable modules",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} unavailable",
"instanceShell.rightPanel.customize.moveUp": "Up",
"instanceShell.rightPanel.customize.moveDown": "Down",
"instanceShell.rightPanel.customize.moveTabUp": "Move {label} tab up",
diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts
index a8237d414..e24672a82 100644
--- a/packages/ui/src/lib/i18n/messages/es/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/es/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
+ "instanceShell.rightPanel.modules.core": "Núcleo de CodeNomad",
+ "instanceShell.rightPanel.modules.core.description": "Paneles integrados de Git, Archivos y Estado.",
"instanceShell.rightPanel.customize.toggle": "Personalizar panel derecho",
"instanceShell.rightPanel.customize.title": "Personalizar panel derecho",
- "instanceShell.rightPanel.customize.description": "Muestra u oculta pestañas y secciones de estado.",
+ "instanceShell.rightPanel.customize.description": "Muestra u oculta módulos del panel derecho.",
"instanceShell.rightPanel.customize.reset": "Restablecer",
"instanceShell.rightPanel.customize.tabs": "Pestañas",
+ "instanceShell.rightPanel.customize.sections": "Personalizar secciones",
"instanceShell.rightPanel.customize.statusSections": "Secciones de estado",
- "instanceShell.rightPanel.customize.dragToReorder": "Arrastra pestañas o secciones de estado para reordenarlas.",
+ "instanceShell.rightPanel.customize.dragToReorder": "Arrastra para reordenar.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "Siempre visible",
+ "instanceShell.rightPanel.customize.unavailableModules": "Módulos no disponibles",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} no disponible",
"instanceShell.rightPanel.customize.moveUp": "Subir",
"instanceShell.rightPanel.customize.moveDown": "Bajar",
"instanceShell.rightPanel.customize.moveTabUp": "Subir pestaña {label}",
diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts
index 87b84b634..51b865dc0 100644
--- a/packages/ui/src/lib/i18n/messages/fr/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
+ "instanceShell.rightPanel.modules.core": "Noyau CodeNomad",
+ "instanceShell.rightPanel.modules.core.description": "Panneaux Git, Fichiers et Statut intégrés.",
"instanceShell.rightPanel.customize.toggle": "Personnaliser le panneau droit",
"instanceShell.rightPanel.customize.title": "Personnaliser le panneau droit",
- "instanceShell.rightPanel.customize.description": "Afficher ou masquer les onglets et sections de statut.",
+ "instanceShell.rightPanel.customize.description": "Afficher ou masquer les modules du panneau droit.",
"instanceShell.rightPanel.customize.reset": "Réinitialiser",
"instanceShell.rightPanel.customize.tabs": "Onglets",
+ "instanceShell.rightPanel.customize.sections": "Personnaliser les sections",
"instanceShell.rightPanel.customize.statusSections": "Sections de statut",
- "instanceShell.rightPanel.customize.dragToReorder": "Faites glisser les onglets ou sections de statut pour les réordonner.",
+ "instanceShell.rightPanel.customize.dragToReorder": "Faites glisser pour réordonner.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "Toujours visible",
+ "instanceShell.rightPanel.customize.unavailableModules": "Modules indisponibles",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} indisponible",
"instanceShell.rightPanel.customize.moveUp": "Monter",
"instanceShell.rightPanel.customize.moveDown": "Descendre",
"instanceShell.rightPanel.customize.moveTabUp": "Monter l'onglet {label}",
diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts
index 4d5a4af43..3e42ff082 100644
--- a/packages/ui/src/lib/i18n/messages/he/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/he/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "קבצים",
"instanceShell.rightPanel.tabs.status": "סטטוס",
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
+ "instanceShell.rightPanel.modules.core": "ליבת CodeNomad",
+ "instanceShell.rightPanel.modules.core.description": "לוחות Git, קבצים וסטטוס מובנים.",
"instanceShell.rightPanel.customize.toggle": "התאמת הלוח הימני",
"instanceShell.rightPanel.customize.title": "התאמת הלוח הימני",
- "instanceShell.rightPanel.customize.description": "הצגה או הסתרה של כרטיסיות ומקטעי סטטוס.",
+ "instanceShell.rightPanel.customize.description": "הצגה או הסתרה של מודולי הלוח הימני.",
"instanceShell.rightPanel.customize.reset": "איפוס",
"instanceShell.rightPanel.customize.tabs": "כרטיסיות",
+ "instanceShell.rightPanel.customize.sections": "התאמת מקטעים",
"instanceShell.rightPanel.customize.statusSections": "מקטעי סטטוס",
- "instanceShell.rightPanel.customize.dragToReorder": "גררו כרטיסיות או מקטעי סטטוס כדי לסדר מחדש.",
+ "instanceShell.rightPanel.customize.dragToReorder": "גררו כדי לסדר מחדש.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "תמיד גלוי",
+ "instanceShell.rightPanel.customize.unavailableModules": "מודולים לא זמינים",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} לא זמין",
"instanceShell.rightPanel.customize.moveUp": "למעלה",
"instanceShell.rightPanel.customize.moveDown": "למטה",
"instanceShell.rightPanel.customize.moveTabUp": "העברת הכרטיסייה {label} למעלה",
diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts
index b4eb22255..51952de54 100644
--- a/packages/ui/src/lib/i18n/messages/ja/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
+ "instanceShell.rightPanel.modules.core": "CodeNomad コア",
+ "instanceShell.rightPanel.modules.core.description": "組み込みの Git、ファイル、ステータスパネル。",
"instanceShell.rightPanel.customize.toggle": "右パネルをカスタマイズ",
"instanceShell.rightPanel.customize.title": "右パネルをカスタマイズ",
- "instanceShell.rightPanel.customize.description": "タブとステータスセクションの表示/非表示を切り替えます。",
+ "instanceShell.rightPanel.customize.description": "右パネルのモジュールの表示/非表示を切り替えます。",
"instanceShell.rightPanel.customize.reset": "リセット",
"instanceShell.rightPanel.customize.tabs": "タブ",
+ "instanceShell.rightPanel.customize.sections": "セクションをカスタマイズ",
"instanceShell.rightPanel.customize.statusSections": "ステータスセクション",
- "instanceShell.rightPanel.customize.dragToReorder": "タブまたはステータスセクションをドラッグして並べ替えます。",
+ "instanceShell.rightPanel.customize.dragToReorder": "ドラッグして並べ替えます。",
+ "instanceShell.rightPanel.customize.alwaysVisible": "常に表示",
+ "instanceShell.rightPanel.customize.unavailableModules": "利用できないモジュール",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} は利用できません",
"instanceShell.rightPanel.customize.moveUp": "上へ",
"instanceShell.rightPanel.customize.moveDown": "下へ",
"instanceShell.rightPanel.customize.moveTabUp": "{label} タブを上へ移動",
diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts
index 7f5e938a4..5bb3f3e0a 100644
--- a/packages/ui/src/lib/i18n/messages/ne/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "फाइलहरू",
"instanceShell.rightPanel.tabs.status": "स्थिति",
"instanceShell.rightPanel.tabs.ariaLabel": "दायाँ प्यानल ट्याबहरू",
+ "instanceShell.rightPanel.modules.core": "CodeNomad कोर",
+ "instanceShell.rightPanel.modules.core.description": "निर्मित Git, फाइलहरू, र स्थिति प्यानलहरू।",
"instanceShell.rightPanel.customize.toggle": "दायाँ प्यानल अनुकूलन गर्नुहोस्",
"instanceShell.rightPanel.customize.title": "दायाँ प्यानल अनुकूलन गर्नुहोस्",
- "instanceShell.rightPanel.customize.description": "ट्याबहरू र स्थिति खण्डहरू देखाउनुहोस् वा लुकाउनुहोस्।",
+ "instanceShell.rightPanel.customize.description": "दायाँ प्यानल मोड्युलहरू देखाउनुहोस् वा लुकाउनुहोस्।",
"instanceShell.rightPanel.customize.reset": "रिसेट गर्नुहोस्",
"instanceShell.rightPanel.customize.tabs": "ट्याबहरू",
+ "instanceShell.rightPanel.customize.sections": "खण्डहरू अनुकूलन गर्नुहोस्",
"instanceShell.rightPanel.customize.statusSections": "स्थिति खण्डहरू",
- "instanceShell.rightPanel.customize.dragToReorder": "पुनःक्रमबद्ध गर्न ट्याब वा स्थिति खण्डहरू तान्नुहोस्।",
+ "instanceShell.rightPanel.customize.dragToReorder": "पुनःक्रमबद्ध गर्न तान्नुहोस्।",
+ "instanceShell.rightPanel.customize.alwaysVisible": "सधैं देखिने",
+ "instanceShell.rightPanel.customize.unavailableModules": "उपलब्ध नभएका मोड्युलहरू",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} उपलब्ध छैन",
"instanceShell.rightPanel.customize.moveUp": "माथि",
"instanceShell.rightPanel.customize.moveDown": "तल",
"instanceShell.rightPanel.customize.moveTabUp": "{label} ट्याब माथि सार्नुहोस्",
diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts
index 44386c157..80437e052 100644
--- a/packages/ui/src/lib/i18n/messages/ru/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
+ "instanceShell.rightPanel.modules.core": "Ядро CodeNomad",
+ "instanceShell.rightPanel.modules.core.description": "Встроенные панели Git, Файлы и Статус.",
"instanceShell.rightPanel.customize.toggle": "Настроить правую панель",
"instanceShell.rightPanel.customize.title": "Настроить правую панель",
- "instanceShell.rightPanel.customize.description": "Показывайте или скрывайте вкладки и секции статуса.",
+ "instanceShell.rightPanel.customize.description": "Показывайте или скрывайте модули правой панели.",
"instanceShell.rightPanel.customize.reset": "Сбросить",
"instanceShell.rightPanel.customize.tabs": "Вкладки",
+ "instanceShell.rightPanel.customize.sections": "Настроить секции",
"instanceShell.rightPanel.customize.statusSections": "Секции статуса",
- "instanceShell.rightPanel.customize.dragToReorder": "Перетаскивайте вкладки или секции статуса, чтобы изменить порядок.",
+ "instanceShell.rightPanel.customize.dragToReorder": "Перетащите, чтобы изменить порядок.",
+ "instanceShell.rightPanel.customize.alwaysVisible": "Всегда видно",
+ "instanceShell.rightPanel.customize.unavailableModules": "Недоступные модули",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} недоступен",
"instanceShell.rightPanel.customize.moveUp": "Вверх",
"instanceShell.rightPanel.customize.moveDown": "Вниз",
"instanceShell.rightPanel.customize.moveTabUp": "Переместить вкладку {label} вверх",
diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
index f1bb74eb6..6a29f2029 100644
--- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts
@@ -93,13 +93,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
+ "instanceShell.rightPanel.modules.core": "CodeNomad 核心",
+ "instanceShell.rightPanel.modules.core.description": "内置 Git、文件和状态面板。",
"instanceShell.rightPanel.customize.toggle": "自定义右侧面板",
"instanceShell.rightPanel.customize.title": "自定义右侧面板",
- "instanceShell.rightPanel.customize.description": "显示或隐藏标签页和状态分区。",
+ "instanceShell.rightPanel.customize.description": "显示或隐藏右侧面板模块。",
"instanceShell.rightPanel.customize.reset": "重置",
"instanceShell.rightPanel.customize.tabs": "标签页",
+ "instanceShell.rightPanel.customize.sections": "自定义分区",
"instanceShell.rightPanel.customize.statusSections": "状态分区",
- "instanceShell.rightPanel.customize.dragToReorder": "拖动标签页或状态分区以重新排序。",
+ "instanceShell.rightPanel.customize.dragToReorder": "拖动以重新排序。",
+ "instanceShell.rightPanel.customize.alwaysVisible": "始终可见",
+ "instanceShell.rightPanel.customize.unavailableModules": "不可用模块",
+ "instanceShell.rightPanel.customize.moduleUnavailable": "{module} 不可用",
"instanceShell.rightPanel.customize.moveUp": "上移",
"instanceShell.rightPanel.customize.moveDown": "下移",
"instanceShell.rightPanel.customize.moveTabUp": "上移 {label} 标签页",
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css
index 1d0bb460c..612bf22f4 100644
--- a/packages/ui/src/styles/panels/right-panel.css
+++ b/packages/ui/src/styles/panels/right-panel.css
@@ -790,16 +790,7 @@
border: 1px solid var(--border-base);
background-color: var(--surface-secondary);
box-shadow: var(--popover-shadow);
- padding: 0.75rem;
-}
-
-.right-panel-customization-header {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- justify-content: space-between;
- gap: 0.75rem;
- margin-bottom: 0.75rem;
+ padding: 0.5rem;
}
.right-panel-customization-grid {
@@ -808,20 +799,6 @@
gap: 0.5rem;
}
-.right-panel-customization-group {
- min-width: 0;
- border: 1px solid var(--border-base);
- background-color: var(--surface-primary);
- padding: 0.5rem;
-}
-
-.right-panel-customization-group-title {
- color: var(--text-secondary);
- font-size: 0.75rem;
- font-weight: 600;
- margin-bottom: 0.35rem;
-}
-
.right-panel-customization-row {
display: flex;
align-items: center;
@@ -830,6 +807,10 @@
padding: 0.25rem 0;
}
+.right-panel-customization-row-indent {
+ padding-inline-start: 1.5rem;
+}
+
.right-panel-customization-label {
min-width: 0;
display: flex;