diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f04ccc12..24e26297 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -104,13 +104,22 @@ const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" -const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" +const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" +const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" +const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1" +const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" +const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1" +const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1" +const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" +const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" +const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" +const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" type LayoutMode = "desktop" | "tablet" | "phone" -type RightPanelTab = "files" | "browser" | "status" +type RightPanelTab = "changes" | "files" | "status" const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) const clampRightWidth = (value: number) => { @@ -133,12 +142,44 @@ function persistPinState(side: "left" | "right", value: boolean) { function readStoredRightPanelTab(defaultValue: RightPanelTab): RightPanelTab { if (typeof window === "undefined") return defaultValue + const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) if (stored === "status") return "status" - if (stored === "browser") return "browser" + if (stored === "changes") return "changes" + if (stored === "files") return "files" + + // Migrate from v1 (where the stored values were the internal tab ids). + const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY) + if (legacy === "status") return "status" + if (legacy === "browser") return "files" + if (legacy === "files") return "changes" + return defaultValue } +function readStoredPanelWidth(key: string, fallback: number) { + if (typeof window === "undefined") return fallback + const stored = window.localStorage.getItem(key) + if (!stored) return fallback + const parsed = Number.parseInt(stored, 10) + return Number.isFinite(parsed) ? parsed : fallback +} + +function readStoredBool(key: string): boolean | null { + if (typeof window === "undefined") return null + const stored = window.localStorage.getItem(key) + if (stored === "true") return true + if (stored === "false") return false + return null +} + +function readStoredEnum(key: string, allowed: readonly T[]): T | null { + if (typeof window === "undefined") return null + const stored = window.localStorage.getItem(key) + if (!stored) return null + return (allowed as readonly string[]).includes(stored) ? (stored as T) : null +} + const InstanceShell2: Component = (props) => { const { t } = useI18n() @@ -162,7 +203,7 @@ const InstanceShell2: Component = (props) => { const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) const [resizeStartX, setResizeStartX] = createSignal(0) const [resizeStartWidth, setResizeStartWidth] = createSignal(0) - const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("files")) + const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("changes")) const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ "plan", "background-processes", @@ -181,8 +222,33 @@ const InstanceShell2: Component = (props) => { const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) const [browserSelectedError, setBrowserSelectedError] = createSignal(null) - const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">("split") - const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">("collapsed") + const [diffViewMode, setDiffViewMode] = createSignal<"split" | "unified">( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", + ) + const [diffContextMode, setDiffContextMode] = createSignal<"expanded" | "collapsed">( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", + ) + + const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) + const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) + const [activeSplitResize, setActiveSplitResize] = createSignal<"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) + + 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 [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal(null) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) @@ -207,6 +273,45 @@ const InstanceShell2: Component = (props) => { const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") + const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone")) + + const listOpenStorageKey = (tab: "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 + } + return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY + } + + const persistListOpen = (tab: "changes" | "files", value: boolean) => { + if (typeof window === "undefined") return + window.localStorage.setItem(listOpenStorageKey(tab), 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 persistPinIfSupported = (side: "left" | "right", value: boolean) => { if (side === "left" && !leftPinningSupported()) return if (side === "right" && !rightPinningSupported()) return @@ -281,6 +386,9 @@ const InstanceShell2: Component = (props) => { setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35)) } + setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320))) + setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) + const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) setSessionSidebarWidth((current) => clampWidth(current || width)) @@ -320,6 +428,16 @@ const InstanceShell2: Component = (props) => { 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(() => { props.tabBarOffset requestAnimationFrame(() => measureDrawerHost()) @@ -765,6 +883,95 @@ const InstanceShell2: Component = (props) => { stopDrawerResize() }) + const clampSplitWidth = (value: number) => { + const min = 200 + const maxByDrawer = Math.max(min, Math.floor(rightDrawerWidth() * 0.65)) + const max = Math.min(560, maxByDrawer) + return Math.min(max, Math.max(min, Math.floor(value))) + } + + const persistSplitWidth = (mode: "changes" | "files", width: number) => { + if (typeof window === "undefined") return + const key = mode === "changes" ? RIGHT_PANEL_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 + document.removeEventListener("mousemove", splitMouseMove) + document.removeEventListener("mouseup", splitMouseUp) + document.removeEventListener("touchmove", splitTouchMove) + document.removeEventListener("touchend", splitTouchEnd) + } + + function splitMouseMove(event: MouseEvent) { + const mode = activeSplitResize() + if (!mode) return + event.preventDefault() + const delta = event.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitMouseUp() { + const mode = activeSplitResize() + if (mode) { + const width = mode === "changes" ? changesSplitWidth() : 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 delta = touch.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitTouchEnd() { + const mode = activeSplitResize() + if (mode) { + const width = mode === "changes" ? changesSplitWidth() : filesSplitWidth() + persistSplitWidth(mode, width) + } + stopSplitResize() + } + + const startSplitResize = (mode: "changes" | "files", clientX: number) => { + if (typeof document === "undefined") return + setActiveSplitResize(mode) + setSplitResizeStartX(clientX) + setSplitResizeStartWidth(mode === "changes" ? changesSplitWidth() : filesSplitWidth()) + document.addEventListener("mousemove", splitMouseMove) + document.addEventListener("mouseup", splitMouseUp) + document.addEventListener("touchmove", splitTouchMove, { passive: false }) + document.addEventListener("touchend", splitTouchEnd) + } + + const handleSplitResizeMouseDown = (mode: "changes" | "files") => (event: MouseEvent) => { + event.preventDefault() + startSplitResize(mode, event.clientX) + } + + const handleSplitResizeTouchStart = (mode: "changes" | "files") => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startSplitResize(mode, touch.clientX) + } + + onCleanup(() => { + stopSplitResize() + }) + type DrawerViewState = "pinned" | "floating-open" | "floating-closed" @@ -1035,6 +1242,36 @@ const InstanceShell2: Component = (props) => { const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer())) + const bestDiffFile = createMemo(() => { + const diffs = 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 = 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 "." @@ -1071,6 +1308,11 @@ const InstanceShell2: Component = (props) => { setBrowserSelectedLoading(true) setBrowserSelectedError(null) setBrowserSelectedContent(null) + + // Phone: treat file selection as a commit action and close the overlay. + if (isPhoneLayout()) { + setFilesListOpen(false) + } try { const content = await requestData(browserClient().file.read({ path }), "file.read") const type = (content as any)?.type @@ -1094,7 +1336,7 @@ const InstanceShell2: Component = (props) => { } createEffect(() => { - if (rightPanelTab() !== "browser") return + if (rightPanelTab() !== "files") return if (browserLoading()) return if (browserEntries() !== null) return void loadBrowserEntries(browserPath()) @@ -1137,9 +1379,23 @@ const InstanceShell2: Component = (props) => { { additions: 0, deletions: 0 }, ) - // Select first file by default if none selected + const mostChanged = sorted.reduce((best, item) => { + const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 + const bestDel = typeof (best as any)?.deletions === "number" ? (best 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 best + return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best + }, sorted[0]) + + // Auto-select the most-changed file if none selected. const currentSelected = selectedFile() - const selectedFileData = sorted.find((f) => f.file === currentSelected) || sorted[0] + const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged const scopeKey = `${props.instance.id}:${sessionId}` @@ -1153,230 +1409,272 @@ const InstanceShell2: Component = (props) => { return false } - if (isPhoneLayout()) { - return ( -
-
-
- - {(item) => ( - - )} - -
-
- -
-
- {t("instanceShell.filesShell.viewerTitle")} -
- - - - -
-
-
- - {t("instanceShell.filesShell.viewerEmpty")} -
- } - > - {(file) => ( - - Binary file cannot be displayed -
- } - > - - - )} - -
- - - ) - } - return (
-
- - {sorted.length} - files - - - +{totals.additions} - additions - - - -{totals.deletions} - deletions +
+ + + + {selectedFileData?.file || ""} + +
+ + +{totals.additions} + + + -{totals.deletions} + +
-
-
-
- {t("instanceShell.filesShell.fileListTitle")} - {sorted.length} -
-
- - {(item) => ( -
setSelectedFile(item.file)} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} -
-
+
+ +
+
+ + + +
- )} - -
-
-
-
- {t("instanceShell.filesShell.viewerTitle")} -
- - - - -
-
-
- - {t("instanceShell.filesShell.viewerEmpty")} -
- } - > - {(file) => ( +
+
- Binary file cannot be displayed + {t("instanceShell.filesShell.viewerEmpty")}
} > - + {(file) => ( + + Binary file cannot be displayed +
+ } + > + + + )} - )} - +
+
+ } + > +
+
+
+ + {(item) => ( +
{ + setSelectedFile(item.file) + if (isPhoneLayout()) { + setChangesListOpen(false) + } + }} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+
+
+ +
-
+ + + + + + +
) @@ -1402,113 +1700,245 @@ const InstanceShell2: Component = (props) => { const parent = getParentPath(browserPath()) const scopeKey = `${props.instance.id}:${worktreeSlugForViewer()}` + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + persistListOpen("files", next) + return next + }) + } + + const headerDisplayedPath = () => browserSelectedPath() || browserPath() + return (
-
- - {browserPath()} - - - Loading… - - - {(err) => {err()}} - +
+ + +
+ + + {headerDisplayedPath()} + + + + Loading… + + + {(err) => {err()}} + +
-
-
-
- Files - {sorted.length} -
-
- - {(p) => ( -
void loadBrowserEntries(p())}> -
-
- .. -
-
-
- )} -
- - - {(item) => ( -
{ - if (item.type === "directory") { - void loadBrowserEntries(item.path) - return - } - void openBrowserFile(item.path) - }} - title={item.path} - > -
-
- {item.name} -
-
- {item.type} -
-
-
- )} -
-
-
- -
-
- Viewer -
-
- + +
- Select a file to preview -
+ + Select a file to preview +
+ } + > + {(payload) => ( + + )} + } > - {(payload) => ( - + {(err) => ( +
+ {err()} +
)} } > - {(err) => ( -
- {err()} +
+ Loading… +
+ +
+
+ } + > +
+
+
+ + {(p) => ( +
void loadBrowserEntries(p())}> +
+
+ .. +
+
)}
- } - > -
- Loading… + + + {(item) => ( +
{ + if (item.type === "directory") { + void loadBrowserEntries(item.path) + return + } + void openBrowserFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+ {item.type} +
+
+
+ )} +
- +
+
-
+ + + + + + +
) @@ -1555,7 +1985,7 @@ const InstanceShell2: Component = (props) => { if (file) { setSelectedFile(file) } - setRightPanelTab("files") + setRightPanelTab("changes") } return ( @@ -1831,18 +2261,18 @@ const InstanceShell2: Component = (props) => { @@ -1864,8 +2294,8 @@ const InstanceShell2: Component = (props) => {
- {renderFilesTabContent()} - {renderBrowserTabContent()} + {renderFilesTabContent()} + {renderBrowserTabContent()} {renderStatusTabContent()}
diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 391b6738..0ecaf812 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -70,18 +70,87 @@ /* Files tab layout */ .files-tab-container { - @apply flex flex-col h-full min-h-0 p-3 gap-3; + @apply flex flex-col h-full min-h-0; +} + +/* Split view (file list + viewer) */ +.files-split { + display: grid; + grid-template-columns: var(--files-pane-width, 320px) 10px minmax(0, 1fr); + min-height: 0; + flex: 1 1 auto; +} + +.file-split-handle { + cursor: col-resize; + background-color: transparent; + border-left: 1px solid var(--border-base); + border-right: 1px solid var(--border-base); + user-select: none; + touch-action: none; +} + +.file-split-handle:hover { + background-color: var(--surface-hover); } .files-tab-header { - @apply flex items-center justify-between gap-2 px-1; + @apply flex items-center justify-between gap-2 px-3 py-2 border-b; + border-color: var(--border-base); } +.files-tab-header-row { + @apply flex items-center gap-2 w-full min-w-0; +} + +.files-toggle-button { + @apply text-[11px] px-2 py-1 border border-base transition-colors; + background-color: var(--surface-base); + color: var(--text-secondary); +} + +.files-toggle-button:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.files-tab-body { + @apply flex flex-col flex-1 min-h-0; + position: relative; +} + +.file-list-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background-color: var(--surface-secondary); + border-left: 1px solid var(--border-base); + z-index: 5; +} + +.file-list-overlay-header { + @apply flex items-center justify-between gap-2 px-3 py-2 border-b; + border-color: var(--border-base); + background-color: var(--surface-secondary); +} + +/* Overlay title intentionally unused; header shows current path instead. */ + .files-tab-stats { @apply flex items-center gap-3 text-[11px]; color: var(--text-muted); } +.files-tab-selected-path { + @apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap; + color: var(--text-primary); + text-overflow: ellipsis; + direction: rtl; + text-align: left; + unicode-bidi: plaintext; +} + .files-tab-stat { @apply flex items-center gap-1.5; } @@ -100,15 +169,14 @@ /* File list panel */ .file-list-panel { - @apply rounded-lg border flex flex-col min-h-0; - background-color: var(--surface-secondary); - border-color: var(--border-base); + @apply flex flex-col min-h-0; + background-color: transparent; } .file-list-header { @apply flex items-center justify-between gap-2 px-3 py-2 border-b; border-color: var(--border-base); - background-color: var(--surface-secondary); + background-color: transparent; } .file-list-title { @@ -173,15 +241,14 @@ /* File viewer panel */ .file-viewer-panel { - @apply rounded-lg border flex flex-col min-h-0; - background-color: var(--surface-secondary); - border-color: var(--border-base); + @apply flex flex-col min-h-0; + background-color: transparent; } .file-viewer-header { @apply flex items-center gap-2 px-3 py-2 border-b; border-color: var(--border-base); - background-color: var(--surface-secondary); + background-color: transparent; } .file-viewer-toolbar {