From 8ce7a9b4eeb6642ad41f87cd68aa1af2e467b1d2 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 11 Feb 2026 08:16:44 +0000 Subject: [PATCH] refactor(ui): modularize instance shell Split InstanceShell2 into focused shell modules (drawer chrome/resize, session context/cache, sidebar, right panel tabs/components) to improve maintainability while preserving behavior. --- AGENTS.md | 12 + .../components/instance/instance-shell2.tsx | 2827 ++--------------- .../instance/shell/SessionSidebar.tsx | 168 + .../instance/shell/right-panel/RightPanel.tsx | 829 +++++ .../right-panel/components/DiffToolbar.tsx | 53 + .../right-panel/components/OverlayList.tsx | 16 + .../right-panel/components/SplitFilePanel.tsx | 70 + .../shell/right-panel/tabs/ChangesTab.tsx | 224 ++ .../shell/right-panel/tabs/FilesTab.tsx | 189 ++ .../shell/right-panel/tabs/GitChangesTab.tsx | 262 ++ .../shell/right-panel/tabs/StatusTab.tsx | 294 ++ .../instance/shell/right-panel/types.ts | 5 + .../src/components/instance/shell/storage.ts | 92 + .../ui/src/components/instance/shell/types.ts | 3 + .../instance/shell/useDrawerChrome.ts | 260 ++ .../instance/shell/useDrawerHostMeasure.ts | 65 + .../instance/shell/useDrawerResize.ts | 113 + .../instance/shell/useGlobalPointerDrag.ts | 29 + .../shell/useInstanceSessionContext.ts | 173 + .../instance/shell/useSessionCache.ts | 99 + .../shell/useSessionSidebarRequests.ts | 109 + 21 files changed, 3249 insertions(+), 2643 deletions(-) create mode 100644 packages/ui/src/components/instance/shell/SessionSidebar.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/types.ts create mode 100644 packages/ui/src/components/instance/shell/storage.ts create mode 100644 packages/ui/src/components/instance/shell/types.ts create mode 100644 packages/ui/src/components/instance/shell/useDrawerChrome.ts create mode 100644 packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts create mode 100644 packages/ui/src/components/instance/shell/useDrawerResize.ts create mode 100644 packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts create mode 100644 packages/ui/src/components/instance/shell/useInstanceSessionContext.ts create mode 100644 packages/ui/src/components/instance/shell/useSessionCache.ts create mode 100644 packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts diff --git a/AGENTS.md b/AGENTS.md index 3017aaea..e9759839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,18 @@ - Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state. - When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions. +## File Length Guidelines (Highlight Only) + +We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits. + +- Source files: warn after ~500 lines; target limit ~800 lines +- Test files: highlight after ~1000 lines + +Behavior for agents: +- Do not refactor solely to satisfy these thresholds. +- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count. +- When creating new files, aim to stay under the thresholds unless there's a clear reason. + ## Tooling Preferences - Use the `edit` tool for modifying existing files; prefer it over other editing methods. - Use the `write` tool only when creating new files from scratch. diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 594fc640..f180d0f7 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -1,7 +1,6 @@ import { For, Show, - batch, createEffect, createMemo, createSignal, @@ -10,75 +9,50 @@ import { type Accessor, type Component, } from "solid-js" -import type { ToolState } from "@opencode-ai/sdk" -import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" -import { Accordion } from "@kobalte/core" -import { ChevronDown, RefreshCw, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" import Drawer from "@suid/material/Drawer" import IconButton from "@suid/material/IconButton" import Toolbar from "@suid/material/Toolbar" -import Typography from "@suid/material/Typography" import useMediaQuery from "@suid/material/useMediaQuery" -import MenuIcon from "@suid/icons-material/Menu" -import MenuOpenIcon from "@suid/icons-material/MenuOpen" -import PushPinIcon from "@suid/icons-material/PushPin" -import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" -import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" import type { Instance } from "../../types/instance" import type { Command } from "../../lib/commands" import type { BackgroundProcess } from "../../../../server/src/api-types" -import type { Session } from "../../types/session" -import { - activeParentSessionId, - activeSessionId as activeSessionMap, - getSessionFamily, - getSessionInfo, - getSessionThreads, - loadMessages, - sessions, - setActiveParentSession, - setActiveSession, -} from "../../stores/sessions" import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry" -import { messageStoreBus } from "../../stores/message-v2/bus" -import { clearSessionRenderCache } from "../message-block" import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette" -import SessionList from "../session-list" -import KeyboardHint from "../keyboard-hint" import Kbd from "../kbd" import InstanceWelcomeView from "../instance-welcome-view" import InfoView from "../info-view" -import InstanceServiceStatus from "../instance-service-status" -import AgentSelector from "../agent-selector" -import ModelSelector from "../model-selector" -import ThinkingSelector from "../thinking-selector" import CommandPalette from "../command-palette" import PermissionNotificationBanner from "../permission-notification-banner" import PermissionApprovalModal from "../permission-approval-modal" -import { TodoListView } from "../tool-call/renderers/todo" -import ContextUsagePanel from "../session/context-usage-panel" import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" -import { requestData } from "../../lib/opencode-api" -import WorktreeSelector from "../worktree-selector" -import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" +import { loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" -import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../stores/worktrees" -import { MonacoDiffViewer } from "../file-viewer/monaco-diff-viewer" -import { MonacoFileViewer } from "../file-viewer/monaco-file-viewer" -import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../lib/unified-diff-reverse" +import SessionSidebar from "./shell/SessionSidebar" +import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests" +import RightPanel from "./shell/right-panel/RightPanel" +import { useDrawerChrome } from "./shell/useDrawerChrome" + +import type { LayoutMode } from "./shell/types" import { - SESSION_SIDEBAR_EVENT, - type SessionSidebarRequestAction, - type SessionSidebarRequestDetail, -} from "../../lib/session-sidebar-events" + DEFAULT_SESSION_SIDEBAR_WIDTH, + LEFT_DRAWER_STORAGE_KEY, + RIGHT_DRAWER_STORAGE_KEY, + RIGHT_DRAWER_WIDTH, + clampRightWidth, + clampWidth, +} from "./shell/storage" +import { useDrawerHostMeasure } from "./shell/useDrawerHostMeasure" +import { useDrawerResize } from "./shell/useDrawerResize" +import { useSessionCache } from "./shell/useSessionCache" +import { useInstanceSessionContext } from "./shell/useInstanceSessionContext" const log = getLogger("session") @@ -94,97 +68,6 @@ interface InstanceShellProps { tabBarOffset: number } -const DEFAULT_SESSION_SIDEBAR_WIDTH = 340 -const MIN_SESSION_SIDEBAR_WIDTH = 220 -const MAX_SESSION_SIDEBAR_WIDTH = 400 -const RIGHT_DRAWER_WIDTH = 260 -const MIN_RIGHT_DRAWER_WIDTH = 200 -const MAX_RIGHT_DRAWER_WIDTH = 1200 -const SESSION_CACHE_LIMIT = 5 -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-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_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-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_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" -const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-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 = "changes" | "git-changes" | "files" | "status" - -const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) -const clampRightWidth = (value: number) => { - const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH - const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax) - return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) -} -const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) -function readStoredPinState(side: "left" | "right", defaultValue: boolean) { - if (typeof window === "undefined") return defaultValue - const stored = window.localStorage.getItem(getPinStorageKey(side)) - if (stored === "true") return true - if (stored === "false") return false - return defaultValue -} -function persistPinState(side: "left" | "right", value: boolean) { - if (typeof window === "undefined") return - window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") -} - -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 === "changes") return "changes" - if (stored === "git-changes") return "git-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() @@ -192,72 +75,11 @@ const InstanceShell2: Component = (props) => { const [rightDrawerWidth, setRightDrawerWidth] = createSignal( typeof window !== "undefined" ? clampRightWidth(window.innerWidth * 0.35) : RIGHT_DRAWER_WIDTH, ) - const [leftPinned, setLeftPinned] = createSignal(true) - const [leftOpen, setLeftOpen] = createSignal(true) - const [rightPinned, setRightPinned] = createSignal(true) - const [rightOpen, setRightOpen] = createSignal(true) - const [cachedSessionIds, setCachedSessionIds] = createSignal([]) - const [pendingEvictions, setPendingEvictions] = createSignal([]) - const [drawerHost, setDrawerHost] = createSignal(null) - const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) - const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + const [rightDrawerWidthInitialized, setRightDrawerWidthInitialized] = createSignal(false) const [leftDrawerContentEl, setLeftDrawerContentEl] = createSignal(null) const [rightDrawerContentEl, setRightDrawerContentEl] = createSignal(null) const [leftToggleButtonEl, setLeftToggleButtonEl] = createSignal(null) const [rightToggleButtonEl, setRightToggleButtonEl] = createSignal(null) - const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null) - const [resizeStartX, setResizeStartX] = createSignal(0) - const [resizeStartWidth, setResizeStartWidth] = createSignal(0) - const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("changes")) - const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ - "plan", - "background-processes", - "mcp", - "lsp", - "plugins", - ]) - const [selectedFile, setSelectedFile] = createSignal(null) - - 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 [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 [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) - const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null) - const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) - const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) - - const [filesListOpen, setFilesListOpen] = createSignal(true) - const [filesListTouched, setFilesListTouched] = createSignal(false) - const [changesListOpen, setChangesListOpen] = createSignal(true) - const [changesListTouched, setChangesListTouched] = createSignal(false) - - const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) - const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) - - 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) @@ -266,7 +88,20 @@ const InstanceShell2: Component = (props) => { // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) - const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) + const { + allInstanceSessions, + sessionThreads, + activeSessions, + activeSessionIdForInstance, + activeSessionForInstance, + activeSessionDiffs, + latestTodoState, + tokenStats, + backgroundProcessList, + handleSessionSelect, + } = useInstanceSessionContext({ + instanceId: () => props.instance.id, + }) const desktopQuery = useMediaQuery("(min-width: 1280px)") @@ -282,62 +117,44 @@ const InstanceShell2: Component = (props) => { const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone") - const listLayoutKey = createMemo(() => (isPhoneLayout() ? "phone" : "nonphone")) + const { setDrawerHost, drawerContainer, measureDrawerHost, floatingTopPx, floatingHeight } = useDrawerHostMeasure( + () => props.tabBarOffset, + ) - const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => { - const layout = listLayoutKey() - if (tab === "changes") { - return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY - } - if (tab === "git-changes") { - return layout === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY - } - return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY - } - - const persistListOpen = (tab: "changes" | "git-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 gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) - if (gitPersisted !== null) { - setGitChangesListOpen(gitPersisted) - setGitChangesListTouched(true) - } else { - setGitChangesListOpen(true) - setGitChangesListTouched(false) - } + const drawerChrome = useDrawerChrome({ + t, + layoutMode, + leftPinningSupported, + rightPinningSupported, + leftDrawerContentEl, + rightDrawerContentEl, + leftToggleButtonEl, + rightToggleButtonEl, + measureDrawerHost, }) - const persistPinIfSupported = (side: "left" | "right", value: boolean) => { - if (side === "left" && !leftPinningSupported()) return - if (side === "right" && !rightPinningSupported()) return - persistPinState(side, value) - } + const { + leftPinned, + leftOpen, + rightPinned, + rightOpen, + setLeftOpen, + setRightOpen, + leftDrawerState, + rightDrawerState, + pinLeft: pinLeftDrawer, + unpinLeft: unpinLeftDrawer, + pinRight: pinRightDrawer, + unpinRight: unpinRightDrawer, + closeLeft: closeLeftDrawer, + closeRight: closeRightDrawer, + leftAppBarButtonLabel, + rightAppBarButtonLabel, + leftAppBarButtonIcon, + rightAppBarButtonIcon, + handleLeftAppBarButtonClick, + handleRightAppBarButtonClick, + } = drawerChrome createEffect(() => { const instanceId = props.instance.id @@ -346,42 +163,6 @@ const InstanceShell2: Component = (props) => { }) }) - createEffect(() => { - switch (layoutMode()) { - case "desktop": { - const leftSaved = readStoredPinState("left", true) - const rightSaved = readStoredPinState("right", true) - setLeftPinned(leftSaved) - setLeftOpen(leftSaved) - setRightPinned(rightSaved) - setRightOpen(rightSaved) - break - } - case "tablet": { - setLeftPinned(true) - setLeftOpen(true) - setRightPinned(false) - setRightOpen(false) - break - } - default: - setLeftPinned(false) - setLeftOpen(false) - setRightPinned(false) - setRightOpen(false) - break - } - }) - - const measureDrawerHost = () => { - if (typeof window === "undefined") return - const host = drawerHost() - if (!host) return - const rect = host.getBoundingClientRect() - setFloatingDrawerTop(rect.top) - setFloatingDrawerHeight(Math.max(0, rect.height)) - } - onMount(() => { if (typeof window === "undefined") return @@ -407,9 +188,7 @@ 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))) - setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) + setRightDrawerWidthInitialized(true) const handleResize = () => { const width = clampWidth(window.innerWidth * 0.3) @@ -424,127 +203,16 @@ const InstanceShell2: Component = (props) => { onCleanup(() => window.removeEventListener("resize", handleResize)) }) - onMount(() => { - if (typeof window === "undefined") return - const handler = (event: Event) => { - const detail = (event as CustomEvent).detail - if (!detail || detail.instanceId !== props.instance.id) return - handleSidebarRequest(detail.action) - } - window.addEventListener(SESSION_SIDEBAR_EVENT, handler) - onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) - }) - - createEffect(() => { - if (typeof window === "undefined") return - window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) - }) + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(LEFT_DRAWER_STORAGE_KEY, sessionSidebarWidth().toString()) + }) createEffect(() => { if (typeof window === "undefined") return window.localStorage.setItem(RIGHT_DRAWER_STORAGE_KEY, rightDrawerWidth().toString()) }) - createEffect(() => { - if (typeof window === "undefined") return - window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) - }) - - createEffect(() => { - if (typeof window === "undefined") return - window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()) - }) - - createEffect(() => { - if (typeof window === "undefined") return - window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) - }) - - createEffect(() => { - props.tabBarOffset - requestAnimationFrame(() => measureDrawerHost()) - }) - - const allInstanceSessions = createMemo>(() => { - return sessions().get(props.instance.id) ?? new Map() - }) - - const sessionThreads = createMemo(() => getSessionThreads(props.instance.id)) - - const activeSessions = createMemo(() => { - const parentId = activeParentSessionId().get(props.instance.id) - if (!parentId) return new Map[number]>() - const sessionFamily = getSessionFamily(props.instance.id, parentId) - return new Map(sessionFamily.map((s) => [s.id, s])) - }) - - const activeSessionIdForInstance = createMemo(() => { - return activeSessionMap().get(props.instance.id) || null - }) - - const parentSessionIdForInstance = createMemo(() => { - return activeParentSessionId().get(props.instance.id) || null - }) - - const activeSessionForInstance = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") return null - return activeSessions().get(sessionId) ?? null - }) - - const activeSessionDiffs = createMemo(() => { - const session = activeSessionForInstance() - return session?.diff - }) - - const activeSessionUsage = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId) return null - const store = messageStore() - return store?.getSessionUsage(sessionId) ?? null - }) - - const activeSessionInfoDetails = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId) return null - return getSessionInfo(props.instance.id, sessionId) ?? null - }) - - const tokenStats = createMemo(() => { - const usage = activeSessionUsage() - const info = activeSessionInfoDetails() - return { - used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, - avail: info?.contextAvailableTokens ?? null, - } - }) - - const latestTodoSnapshot = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") return null - const store = messageStore() - if (!store) return null - const snapshot = store.state.latestTodos[sessionId] - return snapshot ?? null - }) - - const latestTodoState = createMemo(() => { - const snapshot = latestTodoSnapshot() - if (!snapshot) return null - const store = messageStore() - if (!store) return null - const message = store.getMessage(snapshot.messageId) - if (!message) return null - const partRecord = message.parts?.[snapshot.partId] - const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } - if (!part || part.type !== "tool" || part.tool !== "todowrite") return null - const state = part.state - if (!state || state.status !== "completed") return null - return state - }) - - const backgroundProcessList = createMemo(() => getBackgroundProcesses(props.instance.id)) - const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatusClass = () => { const status = connectionStatus() @@ -594,534 +262,39 @@ const InstanceShell2: Component = (props) => { const instancePaletteCommands = createMemo(() => props.paletteCommands()) const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id)) - const keyboardShortcuts = createMemo(() => - [keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter( - (shortcut): shortcut is KeyboardShortcut => Boolean(shortcut), - ), - ) + const keyboardShortcuts = createMemo(() => + [keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter( + (shortcut): shortcut is KeyboardShortcut => Boolean(shortcut), + ), + ) - interface PendingSidebarAction { - action: SessionSidebarRequestAction - id: number - } + useSessionSidebarRequests({ + instanceId: () => props.instance.id, + sidebarContentEl: leftDrawerContentEl, + leftPinned, + leftOpen, + setLeftOpen, + measureDrawerHost, + }) - let sidebarActionId = 0 - const [pendingSidebarAction, setPendingSidebarAction] = createSignal(null) - - const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => { - target.dispatchEvent( - new KeyboardEvent("keydown", { - key: options.key, - code: options.code, - keyCode: options.keyCode, - which: options.keyCode, - bubbles: true, - cancelable: true, - }), - ) - } - - const focusAgentSelectorControl = () => { - const agentTrigger = leftDrawerContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null - if (!agentTrigger) return false - agentTrigger.focus() - setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10) - return true - } - - const focusModelSelectorControl = () => { - const input = leftDrawerContentEl()?.querySelector("[data-model-selector]") - if (!input) return false - input.focus() - setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) - return true - } - - const focusVariantSelectorControl = () => { - const input = leftDrawerContentEl()?.querySelector("[data-thinking-selector]") - if (!input) return false - input.focus() - setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) - return true - } - - createEffect(() => { - const pending = pendingSidebarAction() - if (!pending) return - const action = pending.action - const contentReady = Boolean(leftDrawerContentEl()) - if (!contentReady) { - return - } - if (action === "show-session-list") { - setPendingSidebarAction(null) - return - } - const handled = - action === "focus-agent-selector" - ? focusAgentSelectorControl() - : action === "focus-model-selector" - ? focusModelSelectorControl() - : focusVariantSelectorControl() - if (handled) { - setPendingSidebarAction(null) - } - }) - - const handleSidebarRequest = (action: SessionSidebarRequestAction) => { - setPendingSidebarAction({ action, id: sidebarActionId++ }) - if (!leftPinned() && !leftOpen()) { - setLeftOpen(true) - measureDrawerHost() - } - } - - const closeFloatingDrawersIfAny = () => { - let handled = false - if (!leftPinned() && leftOpen()) { - setLeftOpen(false) - blurIfInside(leftDrawerContentEl()) - focusTarget(leftToggleButtonEl()) - handled = true - } - if (!rightPinned() && rightOpen()) { - setRightOpen(false) - blurIfInside(rightDrawerContentEl()) - focusTarget(rightToggleButtonEl()) - handled = true - } - return handled - } - - onMount(() => { - if (typeof window === "undefined") return - const handleEscape = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - if (!closeFloatingDrawersIfAny()) return - event.preventDefault() - event.stopPropagation() - } - window.addEventListener("keydown", handleEscape, true) - onCleanup(() => window.removeEventListener("keydown", handleEscape, true)) - }) - - const handleSessionSelect = (sessionId: string) => { - if (sessionId === "info") { - setActiveSession(props.instance.id, sessionId) - return - } - - const session = allInstanceSessions().get(sessionId) - if (!session) return - - if (session.parentId === null) { - setActiveParentSession(props.instance.id, sessionId) - return - } - - const parentId = session.parentId - if (!parentId) return - - batch(() => { - setActiveParentSession(props.instance.id, parentId) - setActiveSession(props.instance.id, sessionId) - }) - } - - - const evictSession = (sessionId: string) => { - if (!sessionId) return - log.info("Evicting cached session", { instanceId: props.instance.id, sessionId }) - const store = messageStoreBus.getInstance(props.instance.id) - store?.clearSession(sessionId) - clearSessionRenderCache(props.instance.id, sessionId) - } - - const scheduleEvictions = (ids: string[]) => { - if (!ids.length) return - setPendingEvictions((current) => { - const existing = new Set(current) - const next = [...current] - ids.forEach((id) => { - if (!existing.has(id)) { - next.push(id) - existing.add(id) - } - }) - return next - }) - } - - createEffect(() => { - const pending = pendingEvictions() - if (!pending.length) return - const cached = new Set(cachedSessionIds()) - const remaining: string[] = [] - pending.forEach((id) => { - if (cached.has(id)) { - remaining.push(id) - } else { - evictSession(id) - } - }) - if (remaining.length !== pending.length) { - setPendingEvictions(remaining) - } - }) - - createEffect(() => { - const instanceSessions = allInstanceSessions() - const activeId = activeSessionIdForInstance() - - setCachedSessionIds((current) => { - const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) - - const touch = (id: string | null) => { - if (!id || id === "info") return - if (!instanceSessions.has(id)) return - - const index = next.indexOf(id) - if (index !== -1) { - next.splice(index, 1) - } - next.unshift(id) - } - - touch(activeId) - - const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next - - const trimmedSet = new Set(trimmed) - const removed = current.filter((id) => !trimmedSet.has(id)) - if (removed.length) { - scheduleEvictions(removed) - } - return trimmed - }) + const { cachedSessionIds } = useSessionCache({ + instanceId: () => props.instance.id, + instanceSessions: allInstanceSessions, + activeSessionId: activeSessionIdForInstance, }) const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen()) - const drawerContainer = () => { - const host = drawerHost() - if (host) return host - if (typeof document !== "undefined") { - return document.body - } - return undefined - } - - const fallbackDrawerTop = () => props.tabBarOffset - const floatingTop = () => { - const measured = floatingDrawerTop() - if (measured > 0) return measured - return fallbackDrawerTop() - } - const floatingTopPx = () => `${floatingTop()}px` - const floatingHeight = () => { - const measured = floatingDrawerHeight() - if (measured > 0) return `${measured}px` - return `calc(100% - ${floatingTop()}px)` - } - - const scheduleDrawerMeasure = () => { - if (typeof window === "undefined") { - measureDrawerHost() - return - } - requestAnimationFrame(() => measureDrawerHost()) - } - - const applyDrawerWidth = (side: "left" | "right", width: number) => { - if (side === "left") { - setSessionSidebarWidth(width) - } else { - setRightDrawerWidth(width) - } - scheduleDrawerMeasure() - } - - const handleDrawerPointerMove = (clientX: number) => { - const side = activeResizeSide() - if (!side) return - const startWidth = resizeStartWidth() - const clamp = side === "left" ? clampWidth : clampRightWidth - const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX - const nextWidth = clamp(startWidth + delta) - applyDrawerWidth(side, nextWidth) - } - - function stopDrawerResize() { - setActiveResizeSide(null) - document.removeEventListener("mousemove", drawerMouseMove) - document.removeEventListener("mouseup", drawerMouseUp) - document.removeEventListener("touchmove", drawerTouchMove) - document.removeEventListener("touchend", drawerTouchEnd) - } - - function drawerMouseMove(event: MouseEvent) { - event.preventDefault() - handleDrawerPointerMove(event.clientX) - } - - function drawerMouseUp() { - stopDrawerResize() - } - - function drawerTouchMove(event: TouchEvent) { - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - handleDrawerPointerMove(touch.clientX) - } - - function drawerTouchEnd() { - stopDrawerResize() - } - - const startDrawerResize = (side: "left" | "right", clientX: number) => { - setActiveResizeSide(side) - setResizeStartX(clientX) - setResizeStartWidth(side === "left" ? sessionSidebarWidth() : rightDrawerWidth()) - document.addEventListener("mousemove", drawerMouseMove) - document.addEventListener("mouseup", drawerMouseUp) - document.addEventListener("touchmove", drawerTouchMove, { passive: false }) - document.addEventListener("touchend", drawerTouchEnd) - } - - const handleDrawerResizeMouseDown = (side: "left" | "right") => (event: MouseEvent) => { - event.preventDefault() - startDrawerResize(side, event.clientX) - } - - const handleDrawerResizeTouchStart = (side: "left" | "right") => (event: TouchEvent) => { - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - startDrawerResize(side, touch.clientX) - } - - onCleanup(() => { - stopDrawerResize() + const { handleDrawerResizeMouseDown, handleDrawerResizeTouchStart } = useDrawerResize({ + sessionSidebarWidth, + rightDrawerWidth, + setSessionSidebarWidth, + setRightDrawerWidth, + clampLeft: clampWidth, + clampRight: clampRightWidth, + measureDrawerHost, }) - 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" | "git-changes" | "files", width: number) => { - if (typeof window === "undefined") return - const key = - mode === "changes" - ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY - : mode === "git-changes" - ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY - : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY - window.localStorage.setItem(key, String(width)) - } - - function stopSplitResize() { - setActiveSplitResize(null) - if (typeof document === "undefined") return - 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 if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitMouseUp() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - function splitTouchMove(event: TouchEvent) { - const mode = activeSplitResize() - if (!mode) return - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - const delta = touch.clientX - splitResizeStartX() - const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "changes") setChangesSplitWidth(next) - else if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitTouchEnd() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => { - if (typeof document === "undefined") return - setActiveSplitResize(mode) - setSplitResizeStartX(clientX) - setSplitResizeStartWidth( - mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(), - ) - document.addEventListener("mousemove", splitMouseMove) - document.addEventListener("mouseup", splitMouseUp) - document.addEventListener("touchmove", splitTouchMove, { passive: false }) - document.addEventListener("touchend", splitTouchEnd) - } - - const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => { - event.preventDefault() - startSplitResize(mode, event.clientX) - } - - const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => { - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - startSplitResize(mode, touch.clientX) - } - - onCleanup(() => { - stopSplitResize() - }) - - type DrawerViewState = "pinned" | "floating-open" | "floating-closed" - - - const leftDrawerState = createMemo(() => { - if (leftPinned()) return "pinned" - return leftOpen() ? "floating-open" : "floating-closed" - }) - - const rightDrawerState = createMemo(() => { - if (rightPinned()) return "pinned" - return rightOpen() ? "floating-open" : "floating-closed" - }) - - const leftAppBarButtonLabel = () => { - const state = leftDrawerState() - if (state === "pinned") return t("instanceShell.leftDrawer.toggle.pinned") - return t("instanceShell.leftDrawer.toggle.open") - } - - const rightAppBarButtonLabel = () => { - const state = rightDrawerState() - if (state === "pinned") return t("instanceShell.rightDrawer.toggle.pinned") - return t("instanceShell.rightDrawer.toggle.open") - } - - const leftAppBarButtonIcon = () => { - return - } - - const rightAppBarButtonIcon = () => { - return - } - - - - - const pinLeftDrawer = () => { - blurIfInside(leftDrawerContentEl()) - batch(() => { - setLeftPinned(true) - setLeftOpen(true) - }) - persistPinIfSupported("left", true) - measureDrawerHost() - } - - const unpinLeftDrawer = () => { - blurIfInside(leftDrawerContentEl()) - batch(() => { - setLeftPinned(false) - setLeftOpen(true) - }) - persistPinIfSupported("left", false) - measureDrawerHost() - } - - const pinRightDrawer = () => { - blurIfInside(rightDrawerContentEl()) - batch(() => { - setRightPinned(true) - setRightOpen(true) - }) - persistPinIfSupported("right", true) - measureDrawerHost() - } - - const unpinRightDrawer = () => { - blurIfInside(rightDrawerContentEl()) - batch(() => { - setRightPinned(false) - setRightOpen(true) - }) - persistPinIfSupported("right", false) - measureDrawerHost() - } - - const handleLeftAppBarButtonClick = () => { - const state = leftDrawerState() - if (state !== "floating-closed") return - setLeftOpen(true) - measureDrawerHost() - } - - const handleRightAppBarButtonClick = () => { - const state = rightDrawerState() - if (state !== "floating-closed") return - setRightOpen(true) - measureDrawerHost() - } - - - const focusTarget = (element: HTMLElement | null) => { - if (!element) return - requestAnimationFrame(() => { - element.focus() - }) - } - - const blurIfInside = (element: HTMLElement | null) => { - if (typeof document === "undefined" || !element) return - const active = document.activeElement as HTMLElement | null - if (active && element.contains(active)) { - active.blur() - } - } - - const closeLeftDrawer = () => { - if (leftDrawerState() === "pinned") return - blurIfInside(leftDrawerContentEl()) - setLeftOpen(false) - focusTarget(leftToggleButtonEl()) - } - const closeRightDrawer = () => { - if (rightDrawerState() === "pinned") return - blurIfInside(rightDrawerContentEl()) - setRightOpen(false) - focusTarget(rightToggleButtonEl()) - } - const formattedUsedTokens = () => formatTokenTotal(tokenStats().used) @@ -1133,1720 +306,6 @@ const InstanceShell2: Component = (props) => { return "--" } - const LeftDrawerContent = () => ( -
-
-
- - {t("instanceShell.leftPanel.sessionsTitle")} - -
- setShowSessionSearch((current) => !current)} - sx={{ - color: showSessionSearch() ? "var(--text-primary)" : "inherit", - backgroundColor: showSessionSearch() ? "var(--surface-hover)" : "transparent", - "&:hover": { - backgroundColor: "var(--surface-hover)", - }, - }} - > - - - handleSessionSelect("info")} - > - - - - (leftPinned() ? unpinLeftDrawer() : pinLeftDrawer())} - > - {leftPinned() ? : } - - - - - - - -
-
-
- - - -
-
- -
- { - const result = props.onNewSession() - if (result instanceof Promise) { - void result.catch((error) => log.error("Failed to create session:", error)) - } - }} - enableFilterBar={showSessionSearch()} - showHeader={false} - showFooter={false} - /> - -
- - {(activeSession) => ( - <> -
- - - props.handleSidebarAgentChange(activeSession().id, agent)} - /> - - props.handleSidebarModelChange(activeSession().id, model)} - /> - - - - -
- - )} -
-
-
- ) - - const RightDrawerContent = () => { - const worktreeSlugForViewer = createMemo(() => { - const sessionId = activeSessionIdForInstance() - if (sessionId && sessionId !== "info") { - return getWorktreeSlugForSession(props.instance.id, sessionId) - } - return getDefaultWorktreeSlug(props.instance.id) - }) - - const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instance.id, worktreeSlugForViewer())) - - const [gitStatusEntries, setGitStatusEntries] = createSignal(null) - const [gitStatusLoading, setGitStatusLoading] = createSignal(false) - const [gitStatusError, setGitStatusError] = createSignal(null) - const [gitSelectedPath, setGitSelectedPath] = createSignal(null) - const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) - const [gitSelectedError, setGitSelectedError] = createSignal(null) - const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) - const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) - - const gitMostChangedPath = createMemo(() => { - const entries = gitStatusEntries() - if (!Array.isArray(entries) || entries.length === 0) return null - const candidates = entries.filter((item) => item && item.status !== "deleted") - if (candidates.length === 0) return null - const best = candidates.reduce((currentBest, item) => { - const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) - const score = (item?.added ?? 0) + (item?.removed ?? 0) - if (score > bestScore) return item - if (score < bestScore) return currentBest - return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest - }, candidates[0]) - return typeof best?.path === "string" ? best.path : null - }) - - createEffect(() => { - // Reset tab state when worktree context changes. - worktreeSlugForViewer() - setBrowserPath(".") - setBrowserEntries(null) - setBrowserError(null) - setBrowserSelectedPath(null) - setBrowserSelectedContent(null) - setBrowserSelectedError(null) - setBrowserSelectedLoading(false) - - setGitStatusEntries(null) - setGitStatusError(null) - setGitStatusLoading(false) - setGitSelectedPath(null) - setGitSelectedLoading(false) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) - }) - - const loadGitStatus = async (force = false) => { - if (!force && gitStatusEntries() !== null) return - setGitStatusLoading(true) - setGitStatusError(null) - try { - const list = await requestData(browserClient().file.status(), "file.status") - setGitStatusEntries(Array.isArray(list) ? list : []) - } catch (error) { - setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") - setGitStatusEntries([]) - } finally { - setGitStatusLoading(false) - } - } - - async function openGitFile(path: string) { - setGitSelectedPath(path) - setGitSelectedLoading(true) - setGitSelectedError(null) - setGitSelectedBefore(null) - setGitSelectedAfter(null) - - const list = gitStatusEntries() || [] - const entry = list.find((item) => item.path === path) || null - if (entry?.status === "deleted") { - setGitSelectedError("Deleted file diff is not available yet") - setGitSelectedLoading(false) - return - } - - // Phone: treat file selection as a commit action and close the overlay. - if (isPhoneLayout()) { - setGitChangesListOpen(false) - } - - try { - const content = await requestData(browserClient().file.read({ path }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null - if (afterText === null) { - throw new Error("Unsupported file type") - } - - setGitSelectedAfter(afterText) - - if (entry?.status === "added") { - setGitSelectedBefore("") - return - } - - const diffText = - typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 - ? String((content as any).diff) - : (content as any)?.patch - ? buildUnifiedDiffFromSdkPatch((content as any).patch) - : "" - - const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) - if (beforeText === null) { - throw new Error("Unable to calculate diff for this file") - } - setGitSelectedBefore(beforeText) - } catch (error) { - setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") - } finally { - setGitSelectedLoading(false) - } - } - - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - const entries = gitStatusEntries() - if (entries === null) return - if (gitSelectedPath()) return - const next = gitMostChangedPath() - if (!next) return - void openGitFile(next) - }) - - const refreshGitStatus = async () => { - await loadGitStatus(true) - const selected = gitSelectedPath() - if (selected) { - void openGitFile(selected) - } - } - - 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 "." - 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 }), "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) - - // 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 - 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) - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - - createEffect(() => { - if (rightPanelTab() !== "files") return - if (browserLoading()) return - if (browserEntries() !== null) return - void loadBrowserEntries(browserPath()) - }) - - createEffect(() => { - if (rightPanelTab() !== "git-changes") return - if (gitStatusLoading()) return - if (gitStatusEntries() !== null) return - void loadGitStatus() - }) - - const renderFilesTabContent = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- {t("instanceShell.sessionChanges.noSessionSelected")} -
- ) - } - - const diffs = activeSessionDiffs() - if (diffs === undefined) { - return ( -
- {t("instanceShell.sessionChanges.loading")} -
- ) - } - - if (!Array.isArray(diffs) || diffs.length === 0) { - return ( -
- {t("instanceShell.sessionChanges.empty")} -
- ) - } - - const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.additions === "number" ? item.additions : 0 - acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - 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) || mostChanged - - const scopeKey = `${props.instance.id}:${sessionId}` - - const isBinaryDiff = (item: any) => { - const before = typeof item?.before === "string" ? item.before : "" - const after = typeof item?.after === "string" ? item.after : "" - if (before.length === 0 && after.length === 0) { - // OpenCode stores empty before/after for binaries. - return true - } - return false - } - - return ( -
-
-
- - - - {selectedFileData?.file || ""} - - -
- - +{totals.additions} - - - -{totals.deletions} - -
-
-
- -
- -
-
- - - - -
-
-
- - {t("instanceShell.filesShell.viewerEmpty")} -
- } - > - {(file) => ( - - Binary file cannot be displayed -
- } - > - - - )} - -
-
- } - > -
-
-
- - {(item) => ( -
{ - setSelectedFile(item.file) - if (isPhoneLayout()) { - setChangesListOpen(false) - } - }} - > -
-
- {item.file} -
-
- +{item.additions} - -{item.deletions} -
-
-
- )} -
-
-
- -
- - - - - - - - - - - ) - } - - const renderBrowserTabContent = () => { - if (browserLoading() && browserEntries() === null) { - return ( -
- Loading files... -
- ) - } - - const entries = browserEntries() || [] - const sorted = [...entries].sort((a, b) => { - const aDir = a.type === "directory" ? 0 : 1 - const bDir = b.type === "directory" ? 0 : 1 - if (aDir !== bDir) return aDir - bDir - return String(a.name || "").localeCompare(String(b.name || "")) - }) - - 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() - - const refreshFilesTab = async () => { - 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 }), "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) - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - } - - return ( -
-
-
- - -
- - - {headerDisplayedPath()} - - - - Loading… - - - {(err) => {err()}} - -
- - -
-
- -
- -
- - Select a file to preview -
- } - > - {(payload) => ( - - )} -
- } - > - {(err) => ( -
- {err()} -
- )} - - } - > -
- Loading… -
- -
-
- } - > -
-
-
- - {(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} -
-
-
- )} -
-
-
- -
- - - - - - - - - - ) - } - - const renderGitChangesTabContent = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- Select a session to view changes. -
- ) - } - - const entries = gitStatusEntries() - if (entries === null) { - return ( -
- Loading git changes… -
- ) - } - - const nonDeleted = entries.filter((item) => item && item.status !== "deleted") - if (nonDeleted.length === 0) { - return ( -
- No git changes yet. -
- ) - } - - const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.added === "number" ? item.added : 0 - acc.deletions += typeof item.removed === "number" ? item.removed : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - const selectedPath = gitSelectedPath() - const fallbackPath = gitMostChangedPath() - const selectedEntry = - sorted.find((item) => item.path === selectedPath) || (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) - const scopeKey = `${props.instance.id}:git:${worktreeSlugForViewer()}` - - const toggleGitList = () => { - setGitChangesListTouched(true) - setGitChangesListOpen((current) => { - const next = !current - persistListOpen("git-changes", next) - return next - }) - } - - return ( -
-
-
- - - - {selectedEntry?.path || ""} - - -
- - +{totals.additions} - - - -{totals.deletions} - - - {(err) => {err()}} - -
- - -
-
- -
- -
-
- - - - -
-
-
- - No file selected. -
- } - > - {(file) => ( - - )} -
- } - > - {(err) => ( -
- {err()} -
- )} - - } - > -
- Loading… -
- -
-
- } - > -
-
-
- - {(item) => ( -
{ - void openGitFile(item.path) - }} - > -
-
- {item.path} -
-
- - deleted - - - <> - +{item.added} - -{item.removed} - - -
-
-
- )} -
-
-
- -
- - - - - - - - - - ) - } - - const renderStatusSessionChanges = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- {t("instanceShell.sessionChanges.noSessionSelected")} -
- ) - } - - const diffs = activeSessionDiffs() - if (diffs === undefined) { - return ( -
- {t("instanceShell.sessionChanges.loading")} -
- ) - } - - if (!Array.isArray(diffs) || diffs.length === 0) { - return ( -
- {t("instanceShell.sessionChanges.empty")} -
- ) - } - - const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) - const totals = sorted.reduce( - (acc, item) => { - acc.additions += typeof item.additions === "number" ? item.additions : 0 - acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 - return acc - }, - { additions: 0, deletions: 0 }, - ) - - const openChangesTab = (file?: string) => { - if (file) { - setSelectedFile(file) - } - setRightPanelTab("changes") - } - - return ( -
-
- {t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} - - {`+${totals.additions}`} - {`-${totals.deletions}`} - -
- -
-
- - {(item) => ( - - )} - -
-
-
- ) - } - - const renderPlanSectionContent = () => { - const sessionId = activeSessionIdForInstance() - if (!sessionId || sessionId === "info") { - return ( -
- {t("instanceShell.plan.noSessionSelected")} -
- ) - } - const todoState = latestTodoState() - if (!todoState) { - return ( -
- {t("instanceShell.plan.empty")} -
- ) - } - return - } - - const renderBackgroundProcesses = () => { - const processes = backgroundProcessList() - if (processes.length === 0) { - return ( -
- {t("instanceShell.backgroundProcesses.empty")} -
- ) - } - - return ( -
- - {(process) => ( -
-
- {process.title} -
- {t("instanceShell.backgroundProcesses.status", { status: process.status })} - - - {t("instanceShell.backgroundProcesses.output", { - sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024), - })} - - -
-
-
- - - -
-
- )} -
-
- ) - } - - const statusSections = [ - { - id: "session-changes", - labelKey: "instanceShell.rightPanel.sections.sessionChanges", - render: renderStatusSessionChanges, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - render: renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - render: renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - render: () => ( - - ), - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - render: () => ( - - ), - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - render: () => ( - - ), - }, - ] - - createEffect(() => { - const currentExpanded = new Set(rightPanelExpandedItems()) - if (statusSections.every((section) => currentExpanded.has(section.id))) return - setRightPanelExpandedItems(statusSections.map((section) => section.id)) - }) - - const handleAccordionChange = (values: string[]) => { - setRightPanelExpandedItems(values) - } - - const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id) - - const renderStatusTabContent = () => ( -
- - {(activeSession) => ( - - )} - - - - - {(section) => ( - - - - {t(section.labelKey)} - - - - - {section.render()} - - - )} - - -
- ) - - const tabClass = (tab: RightPanelTab) => - `right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}` - - return ( -
-
-
-
- - - - - - - (rightPinned() ? unpinRightDrawer() : pinRightDrawer())} - > - {rightPinned() ? : } - - -
-
-
-
- - - - -
- -
-
-
-
-
- -
- {renderFilesTabContent()} - {renderGitChangesTabContent()} - {renderBrowserTabContent()} - {renderStatusTabContent()} -
-
- ) - } - const renderLeftPanel = () => { if (leftPinned()) { return ( @@ -2869,7 +328,27 @@ const InstanceShell2: Component = (props) => { role="presentation" aria-hidden="true" /> - + setShowSessionSearch((current) => !current)} + keyboardShortcuts={keyboardShortcuts} + isPhoneLayout={isPhoneLayout} + drawerState={leftDrawerState} + leftPinned={leftPinned} + onSelectSession={handleSessionSelect} + onNewSession={props.onNewSession} + onSidebarAgentChange={props.handleSidebarAgentChange} + onSidebarModelChange={props.handleSidebarModelChange} + onPinLeftDrawer={pinLeftDrawer} + onUnpinLeftDrawer={unpinLeftDrawer} + onCloseLeftDrawer={closeLeftDrawer} + setContentEl={setLeftDrawerContentEl} + /> ) } @@ -2910,7 +389,27 @@ const InstanceShell2: Component = (props) => { aria-hidden="true" /> - + setShowSessionSearch((current) => !current)} + keyboardShortcuts={keyboardShortcuts} + isPhoneLayout={isPhoneLayout} + drawerState={leftDrawerState} + leftPinned={leftPinned} + onSelectSession={handleSessionSelect} + onNewSession={props.onNewSession} + onSidebarAgentChange={props.handleSidebarAgentChange} + onSidebarModelChange={props.handleSidebarModelChange} + onPinLeftDrawer={pinLeftDrawer} + onUnpinLeftDrawer={unpinLeftDrawer} + onCloseLeftDrawer={closeLeftDrawer} + setContentEl={setLeftDrawerContentEl} + /> ) } @@ -2938,7 +437,28 @@ const InstanceShell2: Component = (props) => { role="presentation" aria-hidden="true" /> - + ) } @@ -2978,7 +498,28 @@ const InstanceShell2: Component = (props) => { aria-hidden="true" /> - + ) diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx new file mode 100644 index 00000000..affce5dd --- /dev/null +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -0,0 +1,168 @@ +import { Show, type Accessor, type Component } from "solid-js" +import type { SessionThread } from "../../../stores/session-state" +import type { Session } from "../../../types/session" +import type { KeyboardShortcut } from "../../../lib/keyboard-registry" +import type { DrawerViewState } from "./types" + +import { Search } from "lucide-solid" +import IconButton from "@suid/material/IconButton" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" +import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined" + +import SessionList from "../../session-list" +import KeyboardHint from "../../keyboard-hint" +import Kbd from "../../kbd" +import WorktreeSelector from "../../worktree-selector" +import AgentSelector from "../../agent-selector" +import ModelSelector from "../../model-selector" +import ThinkingSelector from "../../thinking-selector" +import { getLogger } from "../../../lib/logger" + +const log = getLogger("session") + +interface SessionSidebarProps { + t: (key: string) => string + instanceId: string + threads: Accessor + activeSessionId: Accessor + activeSession: Accessor + + showSearch: Accessor + onToggleSearch: () => void + + keyboardShortcuts: Accessor + isPhoneLayout: Accessor + drawerState: Accessor + leftPinned: Accessor + + onSelectSession: (sessionId: string) => void + onNewSession: () => Promise | void + onSidebarAgentChange: (sessionId: string, agent: string) => Promise + onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise + onPinLeftDrawer: () => void + onUnpinLeftDrawer: () => void + onCloseLeftDrawer: () => void + + setContentEl: (el: HTMLElement | null) => void +} + +const SessionSidebar: Component = (props) => ( +
+
+
+ + {props.t("instanceShell.leftPanel.sessionsTitle")} + +
+ + + + props.onSelectSession("info")} + > + + + + (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())} + > + {props.leftPinned() ? : } + + + + + + + +
+
+
+ + + +
+
+ +
+ { + const result = props.onNewSession() + if (result instanceof Promise) { + void result.catch((error) => log.error("Failed to create session:", error)) + } + }} + enableFilterBar={props.showSearch()} + showHeader={false} + showFooter={false} + /> + +
+ + {(activeSession) => ( + <> +
+ + + props.onSidebarAgentChange(activeSession().id, agent)} + /> + + props.onSidebarModelChange(activeSession().id, model)} + /> + + + + +
+ + )} +
+
+
+) + +export default SessionSidebar diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx new file mode 100644 index 00000000..5e240088 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -0,0 +1,829 @@ +import { + Show, + createEffect, + createMemo, + createSignal, + onCleanup, + type Accessor, + type Component, +} from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" +import IconButton from "@suid/material/IconButton" +import MenuOpenIcon from "@suid/icons-material/MenuOpen" +import PushPinIcon from "@suid/icons-material/PushPin" +import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined" + +import type { Instance } from "../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../server/src/api-types" +import type { Session } from "../../../../types/session" +import type { DrawerViewState } from "../types" +import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types" + +import ChangesTab from "./tabs/ChangesTab" +import FilesTab from "./tabs/FilesTab" +import GitChangesTab from "./tabs/GitChangesTab" +import StatusTab from "./tabs/StatusTab" + +import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" +import { requestData } from "../../../../lib/opencode-api" +import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" +import { useGlobalPointerDrag } from "../useGlobalPointerDrag" +import { + RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, + RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_TAB_STORAGE_KEY, + readStoredBool, + readStoredEnum, + readStoredPanelWidth, + readStoredRightPanelTab, +} from "../storage" + +interface RightPanelProps { + t: (key: string, vars?: Record) => string + + instanceId: string + instance: Instance + + activeSessionId: Accessor + activeSession: Accessor + activeSessionDiffs: 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 + rightDrawerState: Accessor + rightPinned: Accessor + onCloseRightDrawer: () => void + onPinRightDrawer: () => void + onUnpinRightDrawer: () => void + + setContentEl: (el: HTMLElement | null) => void +} + +const RightPanel: Component = (props) => { + const [rightPanelTab, setRightPanelTab] = createSignal(readStoredRightPanelTab("changes")) + const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal([ + "plan", + "background-processes", + "mcp", + "lsp", + "plugins", + ]) + const [selectedFile, setSelectedFile] = createSignal(null) + + 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 [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 [changesSplitWidth, setChangesSplitWidth] = createSignal(320) + const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) + const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) + const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null) + const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) + const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) + + const [filesListOpen, setFilesListOpen] = createSignal(true) + const [filesListTouched, setFilesListTouched] = createSignal(false) + const [changesListOpen, setChangesListOpen] = createSignal(true) + const [changesListTouched, setChangesListTouched] = createSignal(false) + const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) + const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) + + const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) + + const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => { + const layout = listLayoutKey() + if (tab === "changes") { + return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY + } + if (tab === "git-changes") { + return layout === "phone" + ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY + : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY + } + return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY + } + + const persistListOpen = (tab: "changes" | "git-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 gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) + if (gitPersisted !== null) { + setGitChangesListOpen(gitPersisted) + setGitChangesListTouched(true) + } else { + setGitChangesListOpen(true) + setGitChangesListTouched(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) + } + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()) + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) + }) + + const clampSplitWidth = (value: number) => { + const min = 200 + const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) + const max = Math.min(560, maxByDrawer) + return Math.min(max, Math.max(min, Math.floor(value))) + } + + const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false) + + createEffect(() => { + if (splitWidthsInitialized()) return + if (!props.rightDrawerWidthInitialized()) return + setSplitWidthsInitialized(true) + setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320))) + setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) + setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) + }) + + const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => { + if (typeof window === "undefined") return + const key = + mode === "changes" + ? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY + : mode === "git-changes" + ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY + : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY + window.localStorage.setItem(key, String(width)) + } + + function stopSplitResize() { + setActiveSplitResize(null) + if (typeof document === "undefined") return + splitPointerDrag.stop() + } + + function splitMouseMove(event: MouseEvent) { + const mode = activeSplitResize() + if (!mode) return + event.preventDefault() + const delta = event.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitMouseUp() { + const mode = activeSplitResize() + if (mode) { + const width = + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + persistSplitWidth(mode, width) + } + stopSplitResize() + } + + function splitTouchMove(event: TouchEvent) { + const mode = activeSplitResize() + if (!mode) return + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + const delta = touch.clientX - splitResizeStartX() + const next = clampSplitWidth(splitResizeStartWidth() + delta) + if (mode === "changes") setChangesSplitWidth(next) + else if (mode === "git-changes") setGitChangesSplitWidth(next) + else setFilesSplitWidth(next) + } + + function splitTouchEnd() { + const mode = activeSplitResize() + if (mode) { + const width = + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() + persistSplitWidth(mode, width) + } + stopSplitResize() + } + + const splitPointerDrag = useGlobalPointerDrag({ + onMouseMove: splitMouseMove, + onMouseUp: splitMouseUp, + onTouchMove: splitTouchMove, + onTouchEnd: splitTouchEnd, + }) + + const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => { + if (typeof document === "undefined") return + setActiveSplitResize(mode) + setSplitResizeStartX(clientX) + setSplitResizeStartWidth( + mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(), + ) + splitPointerDrag.start() + } + + const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => { + event.preventDefault() + startSplitResize(mode, event.clientX) + } + + const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startSplitResize(mode, touch.clientX) + } + + onCleanup(() => { + stopSplitResize() + }) + + const worktreeSlugForViewer = createMemo(() => { + const sessionId = props.activeSessionId() + if (sessionId && sessionId !== "info") { + return getWorktreeSlugForSession(props.instanceId, sessionId) + } + return getDefaultWorktreeSlug(props.instanceId) + }) + + const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) + + const [gitStatusEntries, setGitStatusEntries] = createSignal(null) + const [gitStatusLoading, setGitStatusLoading] = createSignal(false) + const [gitStatusError, setGitStatusError] = createSignal(null) + const [gitSelectedPath, setGitSelectedPath] = createSignal(null) + const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) + const [gitSelectedError, setGitSelectedError] = createSignal(null) + const [gitSelectedBefore, setGitSelectedBefore] = createSignal(null) + const [gitSelectedAfter, setGitSelectedAfter] = createSignal(null) + + const gitMostChangedPath = createMemo(() => { + const entries = gitStatusEntries() + if (!Array.isArray(entries) || entries.length === 0) return null + const candidates = entries.filter((item) => item && item.status !== "deleted") + if (candidates.length === 0) return null + const best = candidates.reduce((currentBest, item) => { + const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) + const score = (item?.added ?? 0) + (item?.removed ?? 0) + if (score > bestScore) return item + if (score < bestScore) return currentBest + return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest + }, candidates[0]) + return typeof best?.path === "string" ? best.path : null + }) + + createEffect(() => { + // Reset tab state when worktree context changes. + worktreeSlugForViewer() + setBrowserPath(".") + setBrowserEntries(null) + setBrowserError(null) + setBrowserSelectedPath(null) + setBrowserSelectedContent(null) + setBrowserSelectedError(null) + setBrowserSelectedLoading(false) + + setGitStatusEntries(null) + setGitStatusError(null) + setGitStatusLoading(false) + setGitSelectedPath(null) + setGitSelectedLoading(false) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) + }) + + const loadGitStatus = async (force = false) => { + if (!force && gitStatusEntries() !== null) return + setGitStatusLoading(true) + setGitStatusError(null) + try { + const list = await requestData(browserClient().file.status(), "file.status") + setGitStatusEntries(Array.isArray(list) ? list : []) + } catch (error) { + setGitStatusError(error instanceof Error ? error.message : "Failed to load git status") + setGitStatusEntries([]) + } finally { + setGitStatusLoading(false) + } + } + + async function openGitFile(path: string) { + setGitSelectedPath(path) + setGitSelectedLoading(true) + setGitSelectedError(null) + setGitSelectedBefore(null) + setGitSelectedAfter(null) + + const list = gitStatusEntries() || [] + const entry = list.find((item) => item.path === path) || null + if (entry?.status === "deleted") { + setGitSelectedError("Deleted file diff is not available yet") + setGitSelectedLoading(false) + return + } + + // Phone: treat file selection as a commit action and close the overlay. + if (props.isPhoneLayout()) { + setGitChangesListOpen(false) + } + + try { + const content = await requestData(browserClient().file.read({ path }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") { + throw new Error("Binary file cannot be displayed") + } + if (encoding === "base64") { + throw new Error("Binary file cannot be displayed") + } + const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null + if (afterText === null) { + throw new Error("Unsupported file type") + } + + setGitSelectedAfter(afterText) + + if (entry?.status === "added") { + setGitSelectedBefore("") + return + } + + const diffText = + typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0 + ? String((content as any).diff) + : (content as any)?.patch + ? buildUnifiedDiffFromSdkPatch((content as any).patch) + : "" + + const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText) + if (beforeText === null) { + throw new Error("Unable to calculate diff for this file") + } + setGitSelectedBefore(beforeText) + } catch (error) { + setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes") + } finally { + setGitSelectedLoading(false) + } + } + + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + const entries = gitStatusEntries() + if (entries === null) return + if (gitSelectedPath()) return + const next = gitMostChangedPath() + if (!next) return + void openGitFile(next) + }) + + const refreshGitStatus = async () => { + await loadGitStatus(true) + const selected = gitSelectedPath() + if (selected) { + void openGitFile(selected) + } + } + + const bestDiffFile = createMemo(() => { + const diffs = props.activeSessionDiffs() + if (!Array.isArray(diffs) || diffs.length === 0) return null + const best = diffs.reduce((currentBest, item) => { + const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0 + const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0 + const bestScore = bestAdd + bestDel + + const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 + const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 + const score = add + del + + if (score > bestScore) return item + if (score < bestScore) return currentBest + return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest + }, diffs[0]) + return typeof (best as any)?.file === "string" ? (best as any).file : null + }) + + createEffect(() => { + const next = bestDiffFile() + if (!next) return + const diffs = props.activeSessionDiffs() + if (!Array.isArray(diffs) || diffs.length === 0) return + + const current = selectedFile() + if (current && diffs.some((d) => d.file === current)) return + setSelectedFile(next) + }) + + const normalizeBrowserPath = (input: string) => { + const raw = String(input || ".").trim() + if (!raw || raw === "./") return "." + const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") + return cleaned === "" ? "." : cleaned + } + + const getParentPath = (path: string): string | null => { + const current = normalizeBrowserPath(path) + if (current === ".") return null + const parts = current.split("/").filter(Boolean) + parts.pop() + return parts.length ? parts.join("/") : "." + } + + const loadBrowserEntries = async (path: string) => { + const normalized = normalizeBrowserPath(path) + setBrowserLoading(true) + setBrowserError(null) + try { + const nodes = await requestData(browserClient().file.list({ path: normalized }), "file.list") + setBrowserPath(normalized) + setBrowserEntries(Array.isArray(nodes) ? nodes : []) + } catch (error) { + setBrowserError(error instanceof Error ? error.message : "Failed to load files") + setBrowserEntries([]) + } finally { + setBrowserLoading(false) + } + } + + const openBrowserFile = async (path: string) => { + setBrowserSelectedPath(path) + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + setBrowserSelectedContent(null) + + // 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 }), "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) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + createEffect(() => { + if (rightPanelTab() !== "files") return + if (browserLoading()) return + if (browserEntries() !== null) return + void loadBrowserEntries(browserPath()) + }) + + createEffect(() => { + if (rightPanelTab() !== "git-changes") return + if (gitStatusLoading()) return + if (gitStatusEntries() !== null) return + void loadGitStatus() + }) + + const handleSelectChangesFile = (file: string, closeList: boolean) => { + setSelectedFile(file) + if (closeList) { + setChangesListOpen(false) + } + } + + const toggleChangesList = () => { + setChangesListTouched(true) + setChangesListOpen((current) => { + const next = !current + persistListOpen("changes", next) + return next + }) + } + + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + persistListOpen("files", next) + return next + }) + } + + const toggleGitList = () => { + setGitChangesListTouched(true) + setGitChangesListOpen((current) => { + const next = !current + persistListOpen("git-changes", next) + return next + }) + } + + const refreshFilesTab = async () => { + 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 }), "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) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + } + + const browserParentPath = createMemo(() => getParentPath(browserPath())) + const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`) + const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`) + + const openChangesTabFromStatus = (file?: string) => { + if (file) { + setSelectedFile(file) + } + setRightPanelTab("changes") + } + + const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"] + + createEffect(() => { + const currentExpanded = new Set(rightPanelExpandedItems()) + if (statusSectionIds.every((id) => currentExpanded.has(id))) return + setRightPanelExpandedItems(statusSectionIds) + }) + + const handleAccordionChange = (values: string[]) => { + setRightPanelExpandedItems(values) + } + + const tabClass = (tab: RightPanelTab) => + `right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}` + + return ( +
+
+
+
+ + + + + + + (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())} + > + {props.rightPinned() ? : } + + +
+
+
+
+ + + + +
+ +
+
+
+
+
+ +
+ + + + + + void openGitFile(path)} + onRefresh={() => void refreshGitStatus()} + listOpen={gitChangesListOpen} + onToggleList={toggleGitList} + splitWidth={gitChangesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} + onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} + isPhoneLayout={props.isPhoneLayout} + /> + + + + void loadBrowserEntries(path)} + onOpenFile={(path) => void openBrowserFile(path)} + onRefresh={() => void refreshFilesTab()} + listOpen={filesListOpen} + onToggleList={toggleFilesList} + splitWidth={filesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("files")} + onResizeTouchStart={handleSplitResizeTouchStart("files")} + isPhoneLayout={props.isPhoneLayout} + /> + + + + + +
+
+ ) +} + +export default RightPanel diff --git a/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx new file mode 100644 index 00000000..beb249ee --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/DiffToolbar.tsx @@ -0,0 +1,53 @@ +import type { Component } from "solid-js" + +import type { DiffContextMode, DiffViewMode } from "../types" + +interface DiffToolbarProps { + viewMode: DiffViewMode + contextMode: DiffContextMode + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void +} + +const DiffToolbar: Component = (props) => { + return ( +
+ + + + +
+ ) +} + +export default DiffToolbar diff --git a/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx b/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx new file mode 100644 index 00000000..43299f68 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/OverlayList.tsx @@ -0,0 +1,16 @@ +import type { Component, JSX } from "solid-js" + +interface OverlayListProps { + ariaLabel: string + children: JSX.Element +} + +const OverlayList: Component = (props) => { + return ( + + ) +} + +export default OverlayList diff --git a/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx b/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx new file mode 100644 index 00000000..56fd6211 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/components/SplitFilePanel.tsx @@ -0,0 +1,70 @@ +import { Show, type Component, type JSX } from "solid-js" + +import OverlayList from "./OverlayList" + +type SplitFilePanelList = { + panel: () => JSX.Element + overlay: () => JSX.Element +} + +interface SplitFilePanelProps { + header: JSX.Element + list: SplitFilePanelList + viewer: JSX.Element + + listOpen: boolean + onToggleList: () => void + + splitWidth: number + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + + isPhoneLayout: boolean + overlayAriaLabel: string +} + +const SplitFilePanel: Component = (props) => { + return ( +
+
+
+ + + {props.header} +
+
+ +
+ +
+
+
{props.list.panel()}
+
+ + + + + + {props.list.overlay()} + + +
+
+ ) +} + +export default SplitFilePanel diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx new file mode 100644 index 00000000..08109ead --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/ChangesTab.tsx @@ -0,0 +1,224 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" + +import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" + +import DiffToolbar from "../components/DiffToolbar" +import SplitFilePanel from "../components/SplitFilePanel" +import type { DiffContextMode, DiffViewMode } from "../types" + +interface ChangesTabProps { + t: (key: string, vars?: Record) => string + + instanceId: string + activeSessionId: Accessor + activeSessionDiffs: Accessor + + selectedFile: Accessor + onSelectFile: (file: string, closeList: boolean) => void + + diffViewMode: Accessor + diffContextMode: Accessor + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const ChangesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.sessionChanges.noSessionSelected")} +
+ ) + } + + const diffs = props.activeSessionDiffs() + if (diffs === undefined) { + return ( +
+ {props.t("instanceShell.sessionChanges.loading")} +
+ ) + } + + if (!Array.isArray(diffs) || diffs.length === 0) { + return ( +
+ {props.t("instanceShell.sessionChanges.empty")} +
+ ) + } + + const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + 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 = props.selectedFile() + const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged + + const scopeKey = `${props.instanceId}:${sessionId}` + + const isBinaryDiff = (item: any) => { + const before = typeof item?.before === "string" ? item.before : "" + const after = typeof item?.after === "string" ? item.after : "" + if (before.length === 0 && after.length === 0) { + // OpenCode stores empty before/after for binaries. + return true + } + return false + } + + const renderViewer = () => ( +
+
+ +
+
+ + {props.t("instanceShell.filesShell.viewerEmpty")} +
+ } + > + {(file) => ( + + Binary file cannot be displayed +
+ } + > + + + )} + +
+
+ ) + + const renderListPanel = () => ( + + {(item) => ( +
{ + props.onSelectFile(item.file, props.isPhoneLayout()) + }} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+ ) + + const renderListOverlay = () => ( + + {(item) => ( +
{ + props.onSelectFile(item.file, true) + }} + title={item.file} + > +
+
+ {item.file} +
+
+ +{item.additions} + -{item.deletions} +
+
+
+ )} +
+ ) + + return ( + + + {selectedFileData?.file || ""} + + +
+ + +{totals.additions} + + + -{totals.deletions} + +
+ + } + list={{ panel: renderListPanel, overlay: renderListOverlay }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Changes" + /> + ) + } + + return <>{renderContent()} +} + +export default ChangesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx new file mode 100644 index 00000000..dcba9267 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/FilesTab.tsx @@ -0,0 +1,189 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import type { FileNode } from "@opencode-ai/sdk/v2/client" + +import { RefreshCw } from "lucide-solid" + +import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer" + +import SplitFilePanel from "../components/SplitFilePanel" + +interface FilesTabProps { + t: (key: string, vars?: Record) => string + + browserPath: Accessor + browserEntries: Accessor + browserLoading: Accessor + browserError: Accessor + + browserSelectedPath: Accessor + browserSelectedContent: Accessor + browserSelectedLoading: Accessor + browserSelectedError: Accessor + + parentPath: Accessor + scopeKey: Accessor + + onLoadEntries: (path: string) => void + onOpenFile: (path: string) => void + onRefresh: () => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const FilesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + if (props.browserLoading() && props.browserEntries() === null) { + return ( +
+ Loading files... +
+ ) + } + + const entries = props.browserEntries() || [] + const sorted = [...entries].sort((a, b) => { + const aDir = a.type === "directory" ? 0 : 1 + const bDir = b.type === "directory" ? 0 : 1 + if (aDir !== bDir) return aDir - bDir + return String(a.name || "").localeCompare(String(b.name || "")) + }) + + const parent = props.parentPath() + + const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() + + const renderViewer = () => ( +
+
+ + Select a file to preview +
+ } + > + {(payload) => ( + + )} + + } + > + {(err) => ( +
+ {err()} +
+ )} + + } + > +
+ Loading… +
+ +
+
+ ) + + const renderList = () => ( + <> + + {(p) => ( +
props.onLoadEntries(p())}> +
+
+ .. +
+
+
+ )} +
+ + + {(item) => ( +
{ + if (item.type === "directory") { + props.onLoadEntries(item.path) + return + } + props.onOpenFile(item.path) + }} + title={item.path} + > +
+
+ {item.name} +
+
+ {item.type} +
+
+
+ )} +
+ + ) + + return ( + +
+ + + {headerDisplayedPath()} + + + + Loading… + + {(err) => {err()}} +
+ + + + } + list={{ panel: renderList, overlay: renderList }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Files" + /> + ) + } + + return <>{renderContent()} +} + +export default FilesTab diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx new file mode 100644 index 00000000..48811f11 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx @@ -0,0 +1,262 @@ +import { For, Show, type Accessor, type Component, type JSX } from "solid-js" +import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" + +import { RefreshCw } from "lucide-solid" + +import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer" + +import DiffToolbar from "../components/DiffToolbar" +import SplitFilePanel from "../components/SplitFilePanel" +import type { DiffContextMode, DiffViewMode } from "../types" + +interface GitChangesTabProps { + t: (key: string, vars?: Record) => string + + activeSessionId: Accessor + + entries: Accessor + statusLoading: Accessor + statusError: Accessor + + selectedPath: Accessor + selectedLoading: Accessor + selectedError: Accessor + selectedBefore: Accessor + selectedAfter: Accessor + mostChangedPath: Accessor + + scopeKey: Accessor + + diffViewMode: Accessor + diffContextMode: Accessor + onViewModeChange: (mode: DiffViewMode) => void + onContextModeChange: (mode: DiffContextMode) => void + + onOpenFile: (path: string) => void + onRefresh: () => void + + listOpen: Accessor + onToggleList: () => void + splitWidth: Accessor + onResizeMouseDown: (event: MouseEvent) => void + onResizeTouchStart: (event: TouchEvent) => void + isPhoneLayout: Accessor +} + +const GitChangesTab: Component = (props) => { + const renderContent = (): JSX.Element => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ Select a session to view changes. +
+ ) + } + + const entries = props.entries() + if (entries === null) { + return ( +
+ Loading git changes… +
+ ) + } + + const nonDeleted = entries.filter((item) => item && item.status !== "deleted") + if (nonDeleted.length === 0) { + return ( +
+ No git changes yet. +
+ ) + } + + const sorted = [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.added === "number" ? item.added : 0 + acc.deletions += typeof item.removed === "number" ? item.removed : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + const selectedPath = props.selectedPath() + const fallbackPath = props.mostChangedPath() + const selectedEntry = + sorted.find((item) => item.path === selectedPath) || + (fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) + + const renderViewer = () => ( +
+
+ +
+
+ + No file selected. +
+ } + > + {(file) => ( + + )} + + } + > + {(err) => ( +
+ {err()} +
+ )} + + } + > +
+ Loading… +
+ +
+
+ ) + + const renderListPanel = () => ( + + {(item) => ( +
{ + props.onOpenFile(item.path) + }} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
+
+
+ )} +
+ ) + + const renderListOverlay = () => ( + + {(item) => ( +
props.onOpenFile(item.path)} + title={item.path} + > +
+
+ {item.path} +
+
+ + deleted + + + <> + +{item.added} + -{item.removed} + + +
+
+
+ )} +
+ ) + + return ( + + + {selectedEntry?.path || ""} + + +
+ + +{totals.additions} + + + -{totals.deletions} + + {(err) => {err()}} +
+ + + + } + list={{ panel: renderListPanel, overlay: renderListOverlay }} + viewer={renderViewer()} + listOpen={props.listOpen()} + onToggleList={props.onToggleList} + splitWidth={props.splitWidth()} + onResizeMouseDown={props.onResizeMouseDown} + onResizeTouchStart={props.onResizeTouchStart} + isPhoneLayout={props.isPhoneLayout()} + overlayAriaLabel="Git Changes" + /> + ) + } + + return <>{renderContent()} +} + +export default GitChangesTab 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 new file mode 100644 index 00000000..6786b455 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -0,0 +1,294 @@ +import { For, Show, type Accessor, type Component } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import { Accordion } from "@kobalte/core" + +import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" + +import type { Instance } from "../../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../../server/src/api-types" +import type { Session } from "../../../../../types/session" + +import ContextUsagePanel from "../../../../session/context-usage-panel" +import { TodoListView } from "../../../../tool-call/renderers/todo" +import InstanceServiceStatus from "../../../../instance-service-status" + +interface StatusTabProps { + t: (key: string, vars?: Record) => string + + instanceId: string + instance: Instance + + activeSessionId: Accessor + activeSession: Accessor + activeSessionDiffs: Accessor + + latestTodoState: Accessor + + backgroundProcessList: Accessor + onOpenBackgroundOutput: (process: BackgroundProcess) => void + onStopBackgroundProcess: (processId: string) => Promise | void + onTerminateBackgroundProcess: (processId: string) => Promise | void + + expandedItems: Accessor + onExpandedItemsChange: (values: string[]) => void + + onOpenChangesTab: (file?: string) => void +} + +const StatusTab: Component = (props) => { + const isSectionExpanded = (id: string) => props.expandedItems().includes(id) + + const renderStatusSessionChanges = () => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.sessionChanges.noSessionSelected")} +
+ ) + } + + const diffs = props.activeSessionDiffs() + if (diffs === undefined) { + return ( +
+ {props.t("instanceShell.sessionChanges.loading")} +
+ ) + } + + if (!Array.isArray(diffs) || diffs.length === 0) { + return ( +
+ {props.t("instanceShell.sessionChanges.empty")} +
+ ) + } + + const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) + const totals = sorted.reduce( + (acc, item) => { + acc.additions += typeof item.additions === "number" ? item.additions : 0 + acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 + return acc + }, + { additions: 0, deletions: 0 }, + ) + + return ( +
+
+ {props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })} + + {`+${totals.additions}`} + {`-${totals.deletions}`} + +
+ +
+
+ + {(item) => ( + + )} + +
+
+
+ ) + } + + const renderPlanSectionContent = () => { + const sessionId = props.activeSessionId() + if (!sessionId || sessionId === "info") { + return ( +
+ {props.t("instanceShell.plan.noSessionSelected")} +
+ ) + } + const todoState = props.latestTodoState() + if (!todoState) { + return ( +
+ {props.t("instanceShell.plan.empty")} +
+ ) + } + return + } + + const renderBackgroundProcesses = () => { + const processes = props.backgroundProcessList() + if (processes.length === 0) { + return ( +
+ {props.t("instanceShell.backgroundProcesses.empty")} +
+ ) + } + + return ( +
+ + {(process) => ( +
+
+ {process.title} +
+ {props.t("instanceShell.backgroundProcesses.status", { status: process.status })} + + + {props.t("instanceShell.backgroundProcesses.output", { + sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024), + })} + + +
+
+
+ + + +
+
+ )} +
+
+ ) + } + + const statusSections = [ + { + id: "session-changes", + labelKey: "instanceShell.rightPanel.sections.sessionChanges", + render: renderStatusSessionChanges, + }, + { + id: "plan", + labelKey: "instanceShell.rightPanel.sections.plan", + render: renderPlanSectionContent, + }, + { + id: "background-processes", + labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", + render: renderBackgroundProcesses, + }, + { + id: "mcp", + labelKey: "instanceShell.rightPanel.sections.mcp", + render: () => ( + + ), + }, + { + id: "lsp", + labelKey: "instanceShell.rightPanel.sections.lsp", + render: () => ( + + ), + }, + { + id: "plugins", + labelKey: "instanceShell.rightPanel.sections.plugins", + render: () => ( + + ), + }, + ] + + return ( +
+ + {(activeSession) => ( + + )} + + + + + {(section) => ( + + + + {props.t(section.labelKey)} + + + + {section.render()} + + )} + + +
+ ) +} + +export default StatusTab diff --git a/packages/ui/src/components/instance/shell/right-panel/types.ts b/packages/ui/src/components/instance/shell/right-panel/types.ts new file mode 100644 index 00000000..e651e528 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/types.ts @@ -0,0 +1,5 @@ +export type RightPanelTab = "changes" | "git-changes" | "files" | "status" + +export type DiffViewMode = "split" | "unified" + +export type DiffContextMode = "expanded" | "collapsed" diff --git a/packages/ui/src/components/instance/shell/storage.ts b/packages/ui/src/components/instance/shell/storage.ts new file mode 100644 index 00000000..5f1d7421 --- /dev/null +++ b/packages/ui/src/components/instance/shell/storage.ts @@ -0,0 +1,92 @@ +export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340 +export const MIN_SESSION_SIDEBAR_WIDTH = 220 +export const MAX_SESSION_SIDEBAR_WIDTH = 400 + +export const RIGHT_DRAWER_WIDTH = 260 +export const MIN_RIGHT_DRAWER_WIDTH = 200 +export const MAX_RIGHT_DRAWER_WIDTH = 1200 + +export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8" +export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1" +export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1" +export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1" +export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2" +export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1" +export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1" +export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1" +export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1" +export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1" +export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1" +export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1" +export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" +export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" +export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" +export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" +export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" + +export const clampWidth = (value: number) => + Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) + +export const clampRightWidth = (value: number) => { + const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH + const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax) + return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value)) +} + +const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY) + +export function readStoredPinState(side: "left" | "right", defaultValue: boolean) { + if (typeof window === "undefined") return defaultValue + const stored = window.localStorage.getItem(getPinStorageKey(side)) + if (stored === "true") return true + if (stored === "false") return false + return defaultValue +} + +export function persistPinState(side: "left" | "right", value: boolean) { + if (typeof window === "undefined") return + window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false") +} + +export function readStoredRightPanelTab( + defaultValue: "changes" | "git-changes" | "files" | "status", +): "changes" | "git-changes" | "files" | "status" { + if (typeof window === "undefined") return defaultValue + + const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY) + if (stored === "status") return "status" + if (stored === "changes") return "changes" + if (stored === "git-changes") return "git-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 +} + +export 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 +} + +export 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 +} + +export 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 +} diff --git a/packages/ui/src/components/instance/shell/types.ts b/packages/ui/src/components/instance/shell/types.ts new file mode 100644 index 00000000..9ca38695 --- /dev/null +++ b/packages/ui/src/components/instance/shell/types.ts @@ -0,0 +1,3 @@ +export type LayoutMode = "desktop" | "tablet" | "phone" + +export type DrawerViewState = "pinned" | "floating-open" | "floating-closed" diff --git a/packages/ui/src/components/instance/shell/useDrawerChrome.ts b/packages/ui/src/components/instance/shell/useDrawerChrome.ts new file mode 100644 index 00000000..bccc8444 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerChrome.ts @@ -0,0 +1,260 @@ +import { + batch, + createComponent, + createEffect, + createMemo, + createSignal, + onCleanup, + onMount, + type Accessor, + type JSX, + type Setter, +} from "solid-js" +import MenuIcon from "@suid/icons-material/Menu" + +import type { TranslateParams } from "../../../lib/i18n" + +import type { DrawerViewState, LayoutMode } from "./types" +import { persistPinState, readStoredPinState } from "./storage" + +export interface UseDrawerChromeOptions { + t: (key: string, params?: TranslateParams) => string + layoutMode: Accessor + leftPinningSupported: Accessor + rightPinningSupported: Accessor + leftDrawerContentEl: Accessor + rightDrawerContentEl: Accessor + leftToggleButtonEl: Accessor + rightToggleButtonEl: Accessor + measureDrawerHost?: () => void +} + +export interface DrawerChromeApi { + leftPinned: Accessor + leftOpen: Accessor + rightPinned: Accessor + rightOpen: Accessor + setLeftOpen: Setter + setRightOpen: Setter + leftDrawerState: Accessor + rightDrawerState: Accessor + pinLeft: () => void + unpinLeft: () => void + pinRight: () => void + unpinRight: () => void + closeLeft: () => void + closeRight: () => void + leftAppBarButtonLabel: Accessor + rightAppBarButtonLabel: Accessor + leftAppBarButtonIcon: Accessor + rightAppBarButtonIcon: Accessor + handleLeftAppBarButtonClick: () => void + handleRightAppBarButtonClick: () => void +} + +export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi { + const [leftPinned, setLeftPinned] = createSignal(true) + const [leftOpen, setLeftOpen] = createSignal(true) + const [rightPinned, setRightPinned] = createSignal(true) + const [rightOpen, setRightOpen] = createSignal(true) + + const measureDrawerHost = () => options.measureDrawerHost?.() + + const focusTarget = (element: HTMLElement | null) => { + if (!element) return + requestAnimationFrame(() => { + element.focus() + }) + } + + const blurIfInside = (element: HTMLElement | null) => { + if (typeof document === "undefined" || !element) return + const active = document.activeElement as HTMLElement | null + if (active && element.contains(active)) { + active.blur() + } + } + + const persistPinIfSupported = (side: "left" | "right", value: boolean) => { + if (side === "left" && !options.leftPinningSupported()) return + if (side === "right" && !options.rightPinningSupported()) return + persistPinState(side, value) + } + + createEffect(() => { + switch (options.layoutMode()) { + case "desktop": { + const leftSaved = readStoredPinState("left", true) + const rightSaved = readStoredPinState("right", true) + setLeftPinned(leftSaved) + setLeftOpen(leftSaved) + setRightPinned(rightSaved) + setRightOpen(rightSaved) + break + } + case "tablet": { + setLeftPinned(true) + setLeftOpen(true) + setRightPinned(false) + setRightOpen(false) + break + } + default: + setLeftPinned(false) + setLeftOpen(false) + setRightPinned(false) + setRightOpen(false) + break + } + }) + + const leftDrawerState = createMemo(() => { + if (leftPinned()) return "pinned" + return leftOpen() ? "floating-open" : "floating-closed" + }) + + const rightDrawerState = createMemo(() => { + if (rightPinned()) return "pinned" + return rightOpen() ? "floating-open" : "floating-closed" + }) + + const leftAppBarButtonLabel = () => { + const state = leftDrawerState() + if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned") + return options.t("instanceShell.leftDrawer.toggle.open") + } + + const rightAppBarButtonLabel = () => { + const state = rightDrawerState() + if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned") + return options.t("instanceShell.rightDrawer.toggle.open") + } + + const leftAppBarButtonIcon = () => { + return createComponent(MenuIcon, { fontSize: "small" }) + } + + const rightAppBarButtonIcon = () => { + return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } }) + } + + const pinLeft = () => { + blurIfInside(options.leftDrawerContentEl()) + batch(() => { + setLeftPinned(true) + setLeftOpen(true) + }) + persistPinIfSupported("left", true) + measureDrawerHost() + } + + const unpinLeft = () => { + blurIfInside(options.leftDrawerContentEl()) + batch(() => { + setLeftPinned(false) + setLeftOpen(true) + }) + persistPinIfSupported("left", false) + measureDrawerHost() + } + + const pinRight = () => { + blurIfInside(options.rightDrawerContentEl()) + batch(() => { + setRightPinned(true) + setRightOpen(true) + }) + persistPinIfSupported("right", true) + measureDrawerHost() + } + + const unpinRight = () => { + blurIfInside(options.rightDrawerContentEl()) + batch(() => { + setRightPinned(false) + setRightOpen(true) + }) + persistPinIfSupported("right", false) + measureDrawerHost() + } + + const handleLeftAppBarButtonClick = () => { + const state = leftDrawerState() + if (state !== "floating-closed") return + setLeftOpen(true) + measureDrawerHost() + } + + const handleRightAppBarButtonClick = () => { + const state = rightDrawerState() + if (state !== "floating-closed") return + setRightOpen(true) + measureDrawerHost() + } + + const closeLeft = () => { + if (leftDrawerState() === "pinned") return + blurIfInside(options.leftDrawerContentEl()) + setLeftOpen(false) + focusTarget(options.leftToggleButtonEl()) + } + + const closeRight = () => { + if (rightDrawerState() === "pinned") return + blurIfInside(options.rightDrawerContentEl()) + setRightOpen(false) + focusTarget(options.rightToggleButtonEl()) + } + + const closeFloatingDrawersIfAny = () => { + let handled = false + if (!leftPinned() && leftOpen()) { + setLeftOpen(false) + blurIfInside(options.leftDrawerContentEl()) + focusTarget(options.leftToggleButtonEl()) + handled = true + } + if (!rightPinned() && rightOpen()) { + setRightOpen(false) + blurIfInside(options.rightDrawerContentEl()) + focusTarget(options.rightToggleButtonEl()) + handled = true + } + return handled + } + + onMount(() => { + if (typeof window === "undefined") return + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + if (!closeFloatingDrawersIfAny()) return + event.preventDefault() + event.stopPropagation() + } + window.addEventListener("keydown", handleEscape, true) + onCleanup(() => window.removeEventListener("keydown", handleEscape, true)) + }) + + return { + leftPinned, + leftOpen, + rightPinned, + rightOpen, + setLeftOpen, + setRightOpen, + leftDrawerState, + rightDrawerState, + pinLeft, + unpinLeft, + pinRight, + unpinRight, + closeLeft, + closeRight, + leftAppBarButtonLabel, + rightAppBarButtonLabel, + leftAppBarButtonIcon, + rightAppBarButtonIcon, + handleLeftAppBarButtonClick, + handleRightAppBarButtonClick, + } +} diff --git a/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts b/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts new file mode 100644 index 00000000..5675424e --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerHostMeasure.ts @@ -0,0 +1,65 @@ +import { createEffect, createSignal, type Accessor } from "solid-js" + +type DrawerHostMeasure = { + setDrawerHost: (element: HTMLElement) => void + drawerContainer: () => HTMLElement | undefined + measureDrawerHost: () => void + floatingTopPx: () => string + floatingHeight: () => string +} + +export function useDrawerHostMeasure(tabBarOffset: Accessor): DrawerHostMeasure { + const [drawerHost, setDrawerHost] = createSignal(null) + const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0) + const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0) + + const storeDrawerHost = (element: HTMLElement) => { + setDrawerHost(element) + } + + const measureDrawerHost = () => { + if (typeof window === "undefined") return + const host = drawerHost() + if (!host) return + const rect = host.getBoundingClientRect() + setFloatingDrawerTop(rect.top) + setFloatingDrawerHeight(Math.max(0, rect.height)) + } + + createEffect(() => { + tabBarOffset() + if (typeof window === "undefined") return + requestAnimationFrame(() => measureDrawerHost()) + }) + + const drawerContainer = () => { + const host = drawerHost() + if (host) return host + if (typeof document !== "undefined") { + return document.body + } + return undefined + } + + const fallbackDrawerTop = () => tabBarOffset() + const floatingTop = () => { + const measured = floatingDrawerTop() + if (measured > 0) return measured + return fallbackDrawerTop() + } + + const floatingTopPx = () => `${floatingTop()}px` + const floatingHeight = () => { + const measured = floatingDrawerHeight() + if (measured > 0) return `${measured}px` + return `calc(100% - ${floatingTop()}px)` + } + + return { + setDrawerHost: storeDrawerHost, + drawerContainer, + measureDrawerHost, + floatingTopPx, + floatingHeight, + } +} diff --git a/packages/ui/src/components/instance/shell/useDrawerResize.ts b/packages/ui/src/components/instance/shell/useDrawerResize.ts new file mode 100644 index 00000000..d3a4f982 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useDrawerResize.ts @@ -0,0 +1,113 @@ +import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js" + +import { useGlobalPointerDrag } from "./useGlobalPointerDrag" + +type DrawerResizeSide = "left" | "right" + +type DrawerResizeOptions = { + sessionSidebarWidth: Accessor + rightDrawerWidth: Accessor + setSessionSidebarWidth: Setter + setRightDrawerWidth: Setter + clampLeft: (width: number) => number + clampRight: (width: number) => number + measureDrawerHost: () => void +} + +type DrawerResizeApi = { + handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void + handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void +} + +export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi { + const [activeResizeSide, setActiveResizeSide] = createSignal(null) + const [resizeStartX, setResizeStartX] = createSignal(0) + const [resizeStartWidth, setResizeStartWidth] = createSignal(0) + + const scheduleDrawerMeasure = () => { + if (typeof window === "undefined") { + options.measureDrawerHost() + return + } + requestAnimationFrame(() => options.measureDrawerHost()) + } + + const applyDrawerWidth = (side: DrawerResizeSide, width: number) => { + if (side === "left") { + options.setSessionSidebarWidth(width) + } else { + options.setRightDrawerWidth(width) + } + scheduleDrawerMeasure() + } + + const handleDrawerPointerMove = (clientX: number) => { + const side = activeResizeSide() + if (!side) return + const startWidth = resizeStartWidth() + const clamp = side === "left" ? options.clampLeft : options.clampRight + const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX + const nextWidth = clamp(startWidth + delta) + applyDrawerWidth(side, nextWidth) + } + + function drawerMouseMove(event: MouseEvent) { + event.preventDefault() + handleDrawerPointerMove(event.clientX) + } + + function drawerMouseUp() { + stopDrawerResize() + } + + function drawerTouchMove(event: TouchEvent) { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + handleDrawerPointerMove(touch.clientX) + } + + function drawerTouchEnd() { + stopDrawerResize() + } + + const drawerPointerDrag = useGlobalPointerDrag({ + onMouseMove: drawerMouseMove, + onMouseUp: drawerMouseUp, + onTouchMove: drawerTouchMove, + onTouchEnd: drawerTouchEnd, + }) + + function stopDrawerResize() { + setActiveResizeSide(null) + drawerPointerDrag.stop() + } + + const startDrawerResize = (side: DrawerResizeSide, clientX: number) => { + setActiveResizeSide(side) + setResizeStartX(clientX) + setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth()) + drawerPointerDrag.start() + } + + const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => { + event.preventDefault() + startDrawerResize(side, event.clientX) + } + + const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startDrawerResize(side, touch.clientX) + } + + onCleanup(() => { + stopDrawerResize() + }) + + return { + handleDrawerResizeMouseDown, + handleDrawerResizeTouchStart, + } +} diff --git a/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts b/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts new file mode 100644 index 00000000..380d4133 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useGlobalPointerDrag.ts @@ -0,0 +1,29 @@ +type GlobalPointerDragHandlers = { + onMouseMove: (event: MouseEvent) => void + onMouseUp: (event: MouseEvent) => void + onTouchMove: (event: TouchEvent) => void + onTouchEnd: (event: TouchEvent) => void +} + +type GlobalPointerDrag = { + start: () => void + stop: () => void +} + +export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag { + const start = () => { + document.addEventListener("mousemove", handlers.onMouseMove) + document.addEventListener("mouseup", handlers.onMouseUp) + document.addEventListener("touchmove", handlers.onTouchMove, { passive: false }) + document.addEventListener("touchend", handlers.onTouchEnd) + } + + const stop = () => { + document.removeEventListener("mousemove", handlers.onMouseMove) + document.removeEventListener("mouseup", handlers.onMouseUp) + document.removeEventListener("touchmove", handlers.onTouchMove) + document.removeEventListener("touchend", handlers.onTouchEnd) + } + + return { start, stop } +} diff --git a/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts new file mode 100644 index 00000000..faee193a --- /dev/null +++ b/packages/ui/src/components/instance/shell/useInstanceSessionContext.ts @@ -0,0 +1,173 @@ +import { batch, createMemo, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" +import type { Session } from "../../../types/session" +import { + activeParentSessionId, + activeSessionId as activeSessionMap, + getSessionFamily, + getSessionInfo, + getSessionThreads, + sessions, + setActiveParentSession, + setActiveSession, +} from "../../../stores/sessions" +import { messageStoreBus } from "../../../stores/message-v2/bus" +import { getBackgroundProcesses } from "../../../stores/background-processes" +import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types" + +type InstanceSessionContextOptions = { + instanceId: Accessor +} + +type InstanceSessionContextState = { + // Session collections and selections + allInstanceSessions: Accessor> + sessionThreads: Accessor> + activeSessions: Accessor> + activeSessionIdForInstance: Accessor + parentSessionIdForInstance: Accessor + activeSessionForInstance: Accessor + activeSessionDiffs: Accessor + + // Usage / info summaries + activeSessionUsage: Accessor + activeSessionInfoDetails: Accessor | null> + tokenStats: Accessor<{ used: number; avail: number | null }> + + // Todo state + latestTodoSnapshot: Accessor + latestTodoState: Accessor + + // Background processes + backgroundProcessList: Accessor> + + // Controller + handleSessionSelect: (sessionId: string) => void +} + +type SessionFamilyMember = ReturnType[number] + +export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState { + const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId())) + + const allInstanceSessions = createMemo>(() => { + return sessions().get(options.instanceId()) ?? new Map() + }) + + const sessionThreads = createMemo(() => getSessionThreads(options.instanceId())) + + const activeSessions = createMemo(() => { + const parentId = activeParentSessionId().get(options.instanceId()) + if (!parentId) return new Map[number]>() + const sessionFamily = getSessionFamily(options.instanceId(), parentId) + return new Map(sessionFamily.map((s) => [s.id, s])) + }) + + const activeSessionIdForInstance = createMemo(() => { + return activeSessionMap().get(options.instanceId()) || null + }) + + const parentSessionIdForInstance = createMemo(() => { + return activeParentSessionId().get(options.instanceId()) || null + }) + + const activeSessionForInstance = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + return activeSessions().get(sessionId) ?? null + }) + + const activeSessionDiffs = createMemo(() => { + const session = activeSessionForInstance() + return session?.diff + }) + + const activeSessionUsage = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + const store = messageStore() + return store?.getSessionUsage(sessionId) ?? null + }) + + const activeSessionInfoDetails = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId) return null + return getSessionInfo(options.instanceId(), sessionId) ?? null + }) + + const tokenStats = createMemo(() => { + const usage = activeSessionUsage() + const info = activeSessionInfoDetails() + return { + used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0, + avail: info?.contextAvailableTokens ?? null, + } + }) + + const latestTodoSnapshot = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + const store = messageStore() + if (!store) return null + const snapshot = store.state.latestTodos[sessionId] + return snapshot ?? null + }) + + const latestTodoState = createMemo(() => { + const snapshot = latestTodoSnapshot() + if (!snapshot) return null + const store = messageStore() + if (!store) return null + const message = store.getMessage(snapshot.messageId) + if (!message) return null + const partRecord = message.parts?.[snapshot.partId] + const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } + if (!part || part.type !== "tool" || part.tool !== "todowrite") return null + const state = part.state + if (!state || state.status !== "completed") return null + return state + }) + + const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId())) + + const handleSessionSelect = (sessionId: string) => { + const instanceId = options.instanceId() + if (sessionId === "info") { + setActiveSession(instanceId, sessionId) + return + } + + const session = allInstanceSessions().get(sessionId) + if (!session) return + + if (session.parentId === null) { + setActiveParentSession(instanceId, sessionId) + return + } + + const parentId = session.parentId + if (!parentId) return + + batch(() => { + setActiveParentSession(instanceId, parentId) + setActiveSession(instanceId, sessionId) + }) + } + + return { + allInstanceSessions, + sessionThreads, + activeSessions, + activeSessionIdForInstance, + parentSessionIdForInstance, + activeSessionForInstance, + activeSessionDiffs, + activeSessionUsage, + activeSessionInfoDetails, + tokenStats, + latestTodoSnapshot, + latestTodoState, + backgroundProcessList, + handleSessionSelect, + } +} diff --git a/packages/ui/src/components/instance/shell/useSessionCache.ts b/packages/ui/src/components/instance/shell/useSessionCache.ts new file mode 100644 index 00000000..0e3a3476 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useSessionCache.ts @@ -0,0 +1,99 @@ +import { createEffect, createSignal, type Accessor } from "solid-js" +import { messageStoreBus } from "../../../stores/message-v2/bus" +import { clearSessionRenderCache } from "../../message-block" +import { getLogger } from "../../../lib/logger" + +const log = getLogger("session") + +const SESSION_CACHE_LIMIT = 5 + +type SessionCacheOptions = { + instanceId: Accessor + instanceSessions: Accessor> + activeSessionId: Accessor +} + +type SessionCacheState = { + cachedSessionIds: Accessor +} + +export function useSessionCache(options: SessionCacheOptions): SessionCacheState { + const [cachedSessionIds, setCachedSessionIds] = createSignal([]) + const [pendingEvictions, setPendingEvictions] = createSignal([]) + + const evictSession = (sessionId: string) => { + if (!sessionId) return + const instanceId = options.instanceId() + log.info("Evicting cached session", { instanceId, sessionId }) + const store = messageStoreBus.getInstance(instanceId) + store?.clearSession(sessionId) + clearSessionRenderCache(instanceId, sessionId) + } + + const scheduleEvictions = (ids: string[]) => { + if (!ids.length) return + setPendingEvictions((current) => { + const existing = new Set(current) + const next = [...current] + ids.forEach((id) => { + if (!existing.has(id)) { + next.push(id) + existing.add(id) + } + }) + return next + }) + } + + createEffect(() => { + const pending = pendingEvictions() + if (!pending.length) return + const cached = new Set(cachedSessionIds()) + const remaining: string[] = [] + pending.forEach((id) => { + if (cached.has(id)) { + remaining.push(id) + } else { + evictSession(id) + } + }) + if (remaining.length !== pending.length) { + setPendingEvictions(remaining) + } + }) + + createEffect(() => { + const instanceSessions = options.instanceSessions() + const activeId = options.activeSessionId() + + setCachedSessionIds((current) => { + const next = current.filter((id) => id !== "info" && instanceSessions.has(id)) + + const touch = (id: string | null) => { + if (!id || id === "info") return + if (!instanceSessions.has(id)) return + + const index = next.indexOf(id) + if (index !== -1) { + next.splice(index, 1) + } + next.unshift(id) + } + + touch(activeId) + + const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next + + const trimmedSet = new Set(trimmed) + const removed = current.filter((id) => !trimmedSet.has(id)) + if (removed.length) { + scheduleEvictions(removed) + } + return trimmed + }) + }) + + return { + cachedSessionIds, + } +} diff --git a/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts b/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts new file mode 100644 index 00000000..3e0a5953 --- /dev/null +++ b/packages/ui/src/components/instance/shell/useSessionSidebarRequests.ts @@ -0,0 +1,109 @@ +import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { + SESSION_SIDEBAR_EVENT, + type SessionSidebarRequestAction, + type SessionSidebarRequestDetail, +} from "../../../lib/session-sidebar-events" + +interface PendingSidebarAction { + action: SessionSidebarRequestAction + id: number +} + +interface UseSessionSidebarRequestsOptions { + instanceId: Accessor + sidebarContentEl: Accessor + leftPinned: Accessor + leftOpen: Accessor + setLeftOpen: (next: boolean) => void + measureDrawerHost: () => void +} + +export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) { + let sidebarActionId = 0 + const [pendingSidebarAction, setPendingSidebarAction] = createSignal(null) + + const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => { + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: options.key, + code: options.code, + keyCode: options.keyCode, + which: options.keyCode, + bubbles: true, + cancelable: true, + }), + ) + } + + const focusAgentSelectorControl = () => { + const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null + if (!agentTrigger) return false + agentTrigger.focus() + setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10) + return true + } + + const focusModelSelectorControl = () => { + const input = options.sidebarContentEl()?.querySelector("[data-model-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + + const focusVariantSelectorControl = () => { + const input = options.sidebarContentEl()?.querySelector("[data-thinking-selector]") + if (!input) return false + input.focus() + setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10) + return true + } + + createEffect(() => { + const pending = pendingSidebarAction() + if (!pending) return + const action = pending.action + const contentReady = Boolean(options.sidebarContentEl()) + if (!contentReady) { + return + } + if (action === "show-session-list") { + setPendingSidebarAction(null) + return + } + const handled = + action === "focus-agent-selector" + ? focusAgentSelectorControl() + : action === "focus-model-selector" + ? focusModelSelectorControl() + : focusVariantSelectorControl() + if (handled) { + setPendingSidebarAction(null) + } + }) + + const handleSidebarRequest = (action: SessionSidebarRequestAction) => { + setPendingSidebarAction({ action, id: sidebarActionId++ }) + if (!options.leftPinned() && !options.leftOpen()) { + options.setLeftOpen(true) + options.measureDrawerHost() + } + } + + onMount(() => { + if (typeof window === "undefined") return + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail + if (!detail || detail.instanceId !== options.instanceId()) return + handleSidebarRequest(detail.action) + } + window.addEventListener(SESSION_SIDEBAR_EVENT, handler) + onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler)) + }) + + return { + handleSidebarRequest, + pendingSidebarAction, + } +}