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.
This commit is contained in:
Shantur Rathore
2026-02-11 08:16:44 +00:00
parent 63ffb86ea7
commit 8ce7a9b4ee
21 changed files with 3249 additions and 2643 deletions

View File

@@ -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<SessionThread[]>
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
showSearch: Accessor<boolean>
onToggleSearch: () => void
keyboardShortcuts: Accessor<KeyboardShortcut[]>
isPhoneLayout: Accessor<boolean>
drawerState: Accessor<DrawerViewState>
leftPinned: Accessor<boolean>
onSelectSession: (sessionId: string) => void
onNewSession: () => Promise<void> | void
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onPinLeftDrawer: () => void
onUnpinLeftDrawer: () => void
onCloseLeftDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
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}
/>
<div class="session-sidebar-separator" />
<Show when={props.activeSession()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
<AgentSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
</div>
</>
)}
</Show>
</div>
</div>
)
export default SessionSidebar

View File

@@ -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, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
isPhoneLayout: Accessor<boolean>
rightDrawerWidth: Accessor<number>
rightDrawerWidthInitialized: Accessor<boolean>
rightDrawerState: Accessor<DrawerViewState>
rightPinned: Accessor<boolean>
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan",
"background-processes",
"mcp",
"lsp",
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
const [browserLoading, setBrowserLoading] = createSignal(false)
const [browserError, setBrowserError] = createSignal<string | null>(null)
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
)
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [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<GitFileStatus[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const gitMostChangedPath = createMemo<string | null>(() => {
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<GitFileStatus[]>(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<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const 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<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
const best = diffs.reduce((currentBest, item) => {
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
}, diffs[0])
return typeof (best as any)?.file === "string" ? (best as any).file : null
})
createEffect(() => {
const next = bestDiffFile()
if (!next) return
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return
const current = selectedFile()
if (current && diffs.some((d) => d.file === current)) return
setSelectedFile(next)
})
const normalizeBrowserPath = (input: string) => {
const raw = String(input || ".").trim()
if (!raw || raw === "./") return "."
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
return cleaned === "" ? "." : cleaned
}
const getParentPath = (path: string): string | null => {
const current = normalizeBrowserPath(path)
if (current === ".") return null
const parts = current.split("/").filter(Boolean)
parts.pop()
return parts.length ? parts.join("/") : "."
}
const loadBrowserEntries = async (path: string) => {
const normalized = normalizeBrowserPath(path)
setBrowserLoading(true)
setBrowserError(null)
try {
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
setBrowserPath(normalized)
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
} catch (error) {
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
setBrowserEntries([])
} finally {
setBrowserLoading(false)
}
}
const openBrowserFile = async (path: string) => {
setBrowserSelectedPath(path)
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setFilesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} 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<FileContent>(browserClient().file.read({ path: selected }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} 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 (
<div class="flex flex-col h-full" ref={props.setContentEl}>
<div class="right-panel-tab-bar">
<div class="tab-container">
<div class="tab-strip-shortcuts text-primary">
<Show when={props.rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
title={props.t("instanceShell.rightDrawer.toggle.close")}
onClick={props.onCloseRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
>
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
<button
type="button"
role="tab"
class={tabClass("changes")}
aria-selected={rightPanelTab() === "changes"}
onClick={() => setRightPanelTab("changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("git-changes")}
aria-selected={rightPanelTab() === "git-changes"}
onClick={() => setRightPanelTab("git-changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("files")}
aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("files")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("status")}
aria-selected={rightPanelTab() === "status"}
onClick={() => setRightPanelTab("status")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
</button>
</div>
<div class="tab-strip-spacer" />
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}>
<ChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "git-changes"}>
<GitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "files"}>
<FilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path) => 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}
/>
</Show>
<Show when={rightPanelTab() === "status"}>
<StatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
</Show>
</div>
</div>
)
}
export default RightPanel

View File

@@ -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<DiffToolbarProps> = (props) => {
return (
<div class="file-viewer-toolbar">
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
aria-pressed={props.viewMode === "split"}
onClick={() => props.onViewModeChange("split")}
>
Split
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
aria-pressed={props.viewMode === "unified"}
onClick={() => props.onViewModeChange("unified")}
>
Unified
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"}
onClick={() => props.onContextModeChange("collapsed")}
title="Hide unchanged regions"
>
Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
</button>
</div>
)
}
export default DiffToolbar

View File

@@ -0,0 +1,16 @@
import type { Component, JSX } from "solid-js"
interface OverlayListProps {
ariaLabel: string
children: JSX.Element
}
const OverlayList: Component<OverlayListProps> = (props) => {
return (
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
<div class="file-list-scroll">{props.children}</div>
</div>
)
}
export default OverlayList

View File

@@ -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<SplitFilePanelProps> = (props) => {
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? "Hide files" : "Show files"}
</button>
{props.header}
</div>
</div>
<div class="files-tab-body">
<Show
when={!props.isPhoneLayout && props.listOpen}
fallback={props.viewer}
>
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
<div class="file-list-panel">
<div class="file-list-scroll">{props.list.panel()}</div>
</div>
<div
class="file-split-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize file list"
onMouseDown={props.onResizeMouseDown}
onTouchStart={props.onResizeTouchStart}
/>
{props.viewer}
</div>
</Show>
<Show when={props.isPhoneLayout}>
<Show when={props.listOpen}>
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
</Show>
</Show>
</div>
</div>
)
}
export default SplitFilePanel

View File

@@ -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, any>) => string
instanceId: string
activeSessionId: Accessor<string | null>
activeSessionDiffs: Accessor<any[] | undefined>
selectedFile: Accessor<string | null>
onSelectFile: (file: string, closeList: boolean) => void
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty">
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
</div>
)
}
const diffs = props.activeSessionDiffs()
if (diffs === undefined) {
return (
<div class="right-panel-empty">
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
</div>
)
}
if (!Array.isArray(diffs) || diffs.length === 0) {
return (
<div class="right-panel-empty">
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
</div>
)
}
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 = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={selectedFileData}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceShell.filesShell.viewerEmpty")}</span>
</div>
}
>
{(file) => (
<Show
when={!isBinaryDiff(file())}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Binary file cannot be displayed</span>
</div>
}
>
<MonacoDiffViewer
scopeKey={scopeKey}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
/>
</Show>
)}
</Show>
</div>
</div>
)
const renderListPanel = () => (
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout())
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
{item.file}
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
)
const renderListOverlay = () => (
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, true)
}}
title={item.file}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
{item.file}
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
)
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={selectedFileData?.file || ""}>
{selectedFileData?.file || ""}
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
</>
}
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

View File

@@ -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, any>) => string
browserPath: Accessor<string>
browserEntries: Accessor<FileNode[] | null>
browserLoading: Accessor<boolean>
browserError: Accessor<string | null>
browserSelectedPath: Accessor<string | null>
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const FilesTab: Component<FilesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
if (props.browserLoading() && props.browserEntries() === null) {
return (
<div class="right-panel-empty">
<span class="text-xs">Loading files...</span>
</div>
)
}
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 = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.browserSelectedLoading()}
fallback={
<Show
when={props.browserSelectedError()}
fallback={
<Show
when={
props.browserSelectedPath() && props.browserSelectedContent() !== null
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Select a file to preview</span>
</div>
}
>
{(payload) => (
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderList = () => (
<>
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
..
</div>
</div>
</div>
)}
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
props.onLoadEntries(item.path)
return
}
props.onOpenFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.name}
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</>
)
return (
<SplitFilePanel
header={
<>
<div class="files-tab-stats">
<span class="files-tab-stat">
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
{headerDisplayedPath()}
</span>
</span>
<Show when={props.browserLoading()}>
<span>Loading</span>
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
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

View File

@@ -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, any>) => string
activeSessionId: Accessor<string | null>
entries: Accessor<GitFileStatus[] | null>
statusLoading: Accessor<boolean>
statusError: Accessor<string | null>
selectedPath: Accessor<string | null>
selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null>
mostChangedPath: Accessor<string | null>
scopeKey: Accessor<string>
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty">
<span class="text-xs">Select a session to view changes.</span>
</div>
)
}
const entries = props.entries()
if (entries === null) {
return (
<div class="right-panel-empty">
<span class="text-xs">Loading git changes</span>
</div>
)
}
const nonDeleted = entries.filter((item) => item && item.status !== "deleted")
if (nonDeleted.length === 0) {
return (
<div class="right-panel-empty">
<span class="text-xs">No git changes yet.</span>
</div>
)
}
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 = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.selectedLoading()}
fallback={
<Show
when={props.selectedError()}
fallback={
<Show
when={
selectedEntry &&
props.selectedBefore() !== null &&
props.selectedAfter() !== null &&
selectedEntry.status !== "deleted"
? {
path: selectedEntry.path,
before: props.selectedBefore() as string,
after: props.selectedAfter() as string,
}
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">No file selected.</span>
</div>
}
>
{(file) => (
<MonacoDiffViewer
scopeKey={props.scopeKey()}
path={String(file().path || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
/>
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderListPanel = () => (
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
props.onOpenFile(item.path)
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.path}
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
)
const renderListOverlay = () => (
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => props.onOpenFile(item.path)}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
{item.path}
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
)
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={selectedEntry?.path || ""}>
{selectedEntry?.path || ""}
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.statusLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
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

View File

@@ -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, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
expandedItems: Accessor<string[]>
onExpandedItemsChange: (values: string[]) => void
onOpenChangesTab: (file?: string) => void
}
const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
</div>
)
}
const diffs = props.activeSessionDiffs()
if (diffs === undefined) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
</div>
)
}
if (!Array.isArray(diffs) || diffs.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
</div>
)
}
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 (
<div class="flex flex-col gap-3 min-h-0">
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
<span class="flex items-center gap-2">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
</span>
</div>
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
<div class="flex flex-col">
<For each={sorted}>
{(item) => (
<button
type="button"
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
onClick={() => props.onOpenChangesTab(item.file)}
title={props.t("instanceShell.sessionChanges.actions.show")}
>
<div class="flex items-center justify-between gap-3">
<div
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
title={item.file}
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
>
{item.file}
</div>
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
</div>
</div>
</button>
)}
</For>
</div>
</div>
</div>
)
}
const renderPlanSectionContent = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
</div>
)
}
const todoState = props.latestTodoState()
if (!todoState) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
</div>
)
}
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
}
const renderBackgroundProcesses = () => {
const processes = props.backgroundProcessList()
if (processes.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
</div>
)
}
return (
<div class="flex flex-col gap-2">
<For each={processes}>
{(process) => (
<div class="status-process-card">
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>
{props.t("instanceShell.backgroundProcesses.output", {
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
})}
</span>
</Show>
</div>
</div>
<div class="status-process-actions">
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onOpenBackgroundOutput(process)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
title={props.t("instanceShell.backgroundProcesses.actions.output")}
>
<TerminalSquare class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"}
onClick={() => props.onStopBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
>
<XOctagon class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onTerminateBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
)}
</For>
</div>
)
}
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: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["mcp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["plugins"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
]
return (
<div class="status-tab-container">
<Show when={props.activeSession()}>
{(activeSession) => (
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
)}
</Show>
<Accordion.Root
class="right-panel-accordion"
collapsible
multiple
value={props.expandedItems()}
onChange={props.onExpandedItemsChange}
>
<For each={statusSections}>
{(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger">
<span>{props.t(section.labelKey)}</span>
<ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion.Root>
</div>
)
}
export default StatusTab

View File

@@ -0,0 +1,5 @@
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"

View File

@@ -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<T extends string>(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
}

View File

@@ -0,0 +1,3 @@
export type LayoutMode = "desktop" | "tablet" | "phone"
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"

View File

@@ -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<LayoutMode>
leftPinningSupported: Accessor<boolean>
rightPinningSupported: Accessor<boolean>
leftDrawerContentEl: Accessor<HTMLElement | null>
rightDrawerContentEl: Accessor<HTMLElement | null>
leftToggleButtonEl: Accessor<HTMLElement | null>
rightToggleButtonEl: Accessor<HTMLElement | null>
measureDrawerHost?: () => void
}
export interface DrawerChromeApi {
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
rightPinned: Accessor<boolean>
rightOpen: Accessor<boolean>
setLeftOpen: Setter<boolean>
setRightOpen: Setter<boolean>
leftDrawerState: Accessor<DrawerViewState>
rightDrawerState: Accessor<DrawerViewState>
pinLeft: () => void
unpinLeft: () => void
pinRight: () => void
unpinRight: () => void
closeLeft: () => void
closeRight: () => void
leftAppBarButtonLabel: Accessor<string>
rightAppBarButtonLabel: Accessor<string>
leftAppBarButtonIcon: Accessor<JSX.Element>
rightAppBarButtonIcon: Accessor<JSX.Element>
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<DrawerViewState>(() => {
if (leftPinned()) return "pinned"
return leftOpen() ? "floating-open" : "floating-closed"
})
const rightDrawerState = createMemo<DrawerViewState>(() => {
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,
}
}

View File

@@ -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<number>): DrawerHostMeasure {
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(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,
}
}

View File

@@ -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<number>
rightDrawerWidth: Accessor<number>
setSessionSidebarWidth: Setter<number>
setRightDrawerWidth: Setter<number>
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<DrawerResizeSide | null>(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,
}
}

View File

@@ -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 }
}

View File

@@ -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<string>
}
type InstanceSessionContextState = {
// Session collections and selections
allInstanceSessions: Accessor<Map<string, Session>>
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
activeSessions: Accessor<Map<string, SessionFamilyMember>>
activeSessionIdForInstance: Accessor<string | null>
parentSessionIdForInstance: Accessor<string | null>
activeSessionForInstance: Accessor<SessionFamilyMember | null>
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
// Usage / info summaries
activeSessionUsage: Accessor<SessionUsageState | null>
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
tokenStats: Accessor<{ used: number; avail: number | null }>
// Todo state
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
latestTodoState: Accessor<ToolState | null>
// Background processes
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
// Controller
handleSessionSelect: (sessionId: string) => void
}
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
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<string, ReturnType<typeof getSessionFamily>[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<ToolState | null>(() => {
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,
}
}

View File

@@ -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<string>
instanceSessions: Accessor<Map<string, unknown>>
activeSessionId: Accessor<string | null>
}
type SessionCacheState = {
cachedSessionIds: Accessor<string[]>
}
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
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,
}
}

View File

@@ -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<string>
sidebarContentEl: Accessor<HTMLElement | null>
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
setLeftOpen: (next: boolean) => void
measureDrawerHost: () => void
}
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(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<HTMLInputElement>("[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<HTMLInputElement>("[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<SessionSidebarRequestDetail>).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,
}
}