feat(ui): add right panel Changes/Status tabs

This commit is contained in:
Shantur Rathore
2026-02-09 16:12:46 +00:00
parent 8c29741830
commit d143faf8eb
10 changed files with 725 additions and 131 deletions

View File

@@ -93,20 +93,26 @@ 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 = 380
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-v1"
type LayoutMode = "desktop" | "tablet" | "phone"
type RightPanelTab = "files" | "status"
const clampWidth = (value: number) => Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
const clampRightWidth = (value: number) => Math.min(MAX_RIGHT_DRAWER_WIDTH, Math.max(MIN_RIGHT_DRAWER_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
@@ -120,11 +126,19 @@ function persistPinState(side: "left" | "right", value: boolean) {
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)
return stored === "status" ? "status" : defaultValue
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n()
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(RIGHT_DRAWER_WIDTH)
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)
@@ -141,6 +155,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [activeResizeSide, setActiveResizeSide] = createSignal<"left" | "right" | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("files"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan",
"background-processes",
@@ -148,6 +163,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
"lsp",
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
const [selectedBackgroundProcess, setSelectedBackgroundProcess] = createSignal<BackgroundProcess | null>(null)
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
@@ -168,7 +184,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const leftPinningSupported = createMemo(() => layoutMode() === "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
@@ -196,11 +212,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
break
}
case "tablet": {
const rightSaved = readStoredPinState("right", true)
setLeftPinned(false)
setLeftOpen(false)
setRightPinned(rightSaved)
setRightOpen(rightSaved)
setLeftPinned(true)
setLeftOpen(true)
setRightPinned(false)
setRightOpen(false)
break
}
default:
@@ -232,17 +247,25 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}
}
let didLoadRightWidth = false
const savedRight = window.localStorage.getItem(RIGHT_DRAWER_STORAGE_KEY)
if (savedRight) {
const parsed = Number.parseInt(savedRight, 10)
if (Number.isFinite(parsed)) {
setRightDrawerWidth(clampRightWidth(parsed))
didLoadRightWidth = true
}
}
if (!didLoadRightWidth) {
setRightDrawerWidth(clampRightWidth(window.innerWidth * 0.35))
}
const handleResize = () => {
const width = clampWidth(window.innerWidth * 0.3)
setSessionSidebarWidth((current) => clampWidth(current || width))
const fallbackRight = window.innerWidth * 0.35
setRightDrawerWidth((current) => clampRightWidth(current || fallbackRight))
measureDrawerHost()
}
@@ -272,6 +295,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
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(() => {
props.tabBarOffset
requestAnimationFrame(() => measureDrawerHost())
@@ -965,19 +993,31 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
)
const RightDrawerContent = () => {
const renderSessionChanges = () => {
const renderFilesTabContent = () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") {
return <p class="text-xs text-secondary">{t("instanceShell.sessionChanges.noSessionSelected")}</p>
return (
<div class="right-panel-empty">
<span class="text-xs">{t("instanceShell.sessionChanges.noSessionSelected")}</span>
</div>
)
}
const diffs = activeSessionDiffs()
if (diffs === undefined) {
return <p class="text-xs text-secondary">{t("instanceShell.sessionChanges.loading")}</p>
return (
<div class="right-panel-empty">
<span class="text-xs">{t("instanceShell.sessionChanges.loading")}</span>
</div>
)
}
if (!Array.isArray(diffs) || diffs.length === 0) {
return <p class="text-xs text-secondary">{t("instanceShell.sessionChanges.empty")}</p>
return (
<div class="right-panel-empty">
<span class="text-xs">{t("instanceShell.sessionChanges.empty")}</span>
</div>
)
}
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
@@ -990,47 +1030,119 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{ 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>{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>
// Select first file by default if none selected
const currentSelected = selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || sorted[0]
<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) => (
<div class="py-2 border-b border-base last:border-b-0">
<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>
if (isPhoneLayout()) {
return (
<div class="files-tab-container">
<div class="mobile-file-selector">
<span class="mobile-file-selector-label">{t("instanceShell.filesShell.mobileSelectorLabel")}</span>
<button type="button" class="selector-trigger mobile-file-selector-trigger" disabled>
<span class="selector-trigger-label selector-trigger-primary selector-trigger-primary--align-left truncate">
{selectedFileData?.file || t("instanceShell.filesShell.mobileSelectorEmpty")}
</span>
<span class="selector-trigger-icon" aria-hidden="true">
<ChevronDown class="w-3 h-3" />
</span>
</button>
</div>
<div class="mobile-file-viewer">
<div class="file-viewer-header">
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
</div>
<div class="file-viewer-content">
<Show
when={selectedFileData}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{t("instanceShell.filesShell.viewerEmpty")}</span>
</div>
</div>
)}
</For>
}
>
{(file) => (
<div class="file-viewer-selected-file">
<span class="file-viewer-file-name">{file().file}</span>
<p class="file-viewer-placeholder">{t("instanceShell.filesShell.viewerPlaceholder")}</p>
</div>
)}
</Show>
</div>
</div>
</div>
)
}
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-stats">
<span class="files-tab-stat">
<span class="files-tab-stat-value">{sorted.length}</span>
<span>files</span>
</span>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
<span>additions</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
<span>deletions</span>
</span>
</div>
</div>
<button
type="button"
class="button-tertiary w-full p-1.5 inline-flex items-center justify-center"
onClick={() => undefined}
>
{t("instanceShell.sessionChanges.actions.show")}
</button>
<div class="flex min-h-0 gap-3 flex-1">
<div class="file-list-panel">
<div class="file-list-header">
<span class="file-list-title">{t("instanceShell.filesShell.fileListTitle")}</span>
<span class="file-list-count">{sorted.length}</span>
</div>
<div class="file-list-scroll">
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => setSelectedFile(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>
</div>
</div>
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<span class="file-viewer-title">{t("instanceShell.filesShell.viewerTitle")}</span>
</div>
<div class="file-viewer-content">
<Show
when={selectedFileData}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{t("instanceShell.filesShell.viewerEmpty")}</span>
</div>
}
>
{(file) => (
<div class="file-viewer-selected-file">
<span class="file-viewer-file-name">{file().file}</span>
<p class="file-viewer-placeholder">{t("instanceShell.filesShell.viewerPlaceholder")}</p>
</div>
)}
</Show>
</div>
</div>
</div>
</div>
)
}
@@ -1038,11 +1150,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const renderPlanSectionContent = () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") {
return <p class="text-xs text-secondary">{t("instanceShell.plan.noSessionSelected")}</p>
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{t("instanceShell.plan.noSessionSelected")}</span>
</div>
)
}
const todoState = latestTodoState()
if (!todoState) {
return <p class="text-xs text-secondary">{t("instanceShell.plan.empty")}</p>
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{t("instanceShell.plan.empty")}</span>
</div>
)
}
return <TodoListView state={todoState} emptyLabel={t("instanceShell.plan.empty")} showStatusLabel={false} />
}
@@ -1050,17 +1170,21 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const renderBackgroundProcesses = () => {
const processes = backgroundProcessList()
if (processes.length === 0) {
return <p class="text-xs text-secondary">{t("instanceShell.backgroundProcesses.empty")}</p>
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{t("instanceShell.backgroundProcesses.empty")}</span>
</div>
)
}
return (
<div class="flex flex-col gap-2">
<For each={processes}>
{(process) => (
<div class="rounded-md border border-base bg-surface-secondary p-2 flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-primary">{process.title}</span>
<div class="flex flex-wrap gap-2 text-[11px] text-secondary">
<div class="status-process-card">
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span>{t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>
@@ -1071,7 +1195,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
</div>
<div class="grid grid-cols-3 gap-2">
<div class="status-process-actions">
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
@@ -1108,12 +1232,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
)
}
const sections = [
{
id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
render: renderSessionChanges,
},
const statusSections = [
{
id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan",
@@ -1164,8 +1283,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems())
if (sections.every((section) => currentExpanded.has(section.id))) return
setRightPanelExpandedItems(sections.map((section) => section.id))
if (statusSections.every((section) => currentExpanded.has(section.id))) return
setRightPanelExpandedItems(statusSections.map((section) => section.id))
})
const handleAccordionChange = (values: string[]) => {
@@ -1174,78 +1293,112 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const isSectionExpanded = (id: string) => rightPanelExpandedItems().includes(id)
const renderStatusTabContent = () => (
<div class="status-tab-container">
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<ContextUsagePanel
instanceId={props.instance.id}
sessionId={activeSession().id}
class="status-tab-context-panel"
/>
)}
</Show>
<Accordion.Root
class="right-panel-accordion"
collapsible
multiple
value={rightPanelExpandedItems()}
onChange={handleAccordionChange}
>
<For each={statusSections}>
{(section) => (
<Accordion.Item
value={section.id}
class="right-panel-accordion-item"
>
<Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger">
<span>{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>
)
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={setRightDrawerContentEl}>
<div class="border-b border-base text-primary">
<div class="relative flex items-center px-4 py-2">
<div class="flex items-center gap-2">
<Show when={rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.rightDrawer.toggle.close")}
title={t("instanceShell.rightDrawer.toggle.close")}
onClick={closeRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{t("instanceShell.rightPanel.title")}
</span>
<div class="right-panel-tab-bar">
<div class="tab-container">
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-shortcuts text-primary">
<Show when={rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={t("instanceShell.rightDrawer.toggle.close")}
title={t("instanceShell.rightDrawer.toggle.close")}
onClick={closeRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={rightPinned() ? t("instanceShell.rightDrawer.unpin") : t("instanceShell.rightDrawer.pin")}
onClick={() => (rightPinned() ? unpinRightDrawer() : pinRightDrawer())}
>
{rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="tab-strip-tabs" role="tablist" aria-label={t("instanceShell.rightPanel.tabs.ariaLabel")}>
<button
type="button"
role="tab"
class={tabClass("files")}
aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("files")}
>
<span class="tab-label">{t("instanceShell.rightPanel.tabs.changes")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("status")}
aria-selected={rightPanelTab() === "status"}
onClick={() => setRightPanelTab("status")}
>
<span class="tab-label">{t("instanceShell.rightPanel.tabs.status")}</span>
</button>
</div>
<div class="tab-strip-spacer" />
</div>
</div>
</div>
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<ContextUsagePanel
instanceId={props.instance.id}
sessionId={activeSession().id}
class="border-t border-base"
/>
)}
</Show>
</div>
<div class="flex-1 overflow-y-auto">
<Accordion.Root
class="flex flex-col"
collapsible
multiple
value={rightPanelExpandedItems()}
onChange={handleAccordionChange}
>
<For each={sections}>
{(section) => (
<Accordion.Item
value={section.id}
class="w-full border border-base bg-surface-secondary text-primary"
>
<Accordion.Header>
<Accordion.Trigger class="w-full flex items-center justify-between gap-3 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide">
<span>{t(section.labelKey)}</span>
<ChevronDown
class={`h-4 w-4 transition-transform duration-150 ${isSectionExpanded(section.id) ? "rotate-180" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="w-full px-3 pb-3 text-sm text-primary">
{section.render()}
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion.Root>
<Show when={rightPanelTab() === "files"}>{renderFilesTabContent()}</Show>
<Show when={rightPanelTab() === "status"}>{renderStatusTabContent()}</Show>
</div>
</div>
)
@@ -1305,6 +1458,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
},
}}
>
<Show when={!isPhoneLayout()}>
<div
class="session-resize-handle session-resize-handle--left"
onMouseDown={handleDrawerResizeMouseDown("left")}
onTouchStart={handleDrawerResizeTouchStart("left")}
role="presentation"
aria-hidden="true"
/>
</Show>
<LeftDrawerContent />
</Drawer>
)
@@ -1364,6 +1526,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
},
}}
>
<Show when={!isPhoneLayout()}>
<div
class="session-resize-handle session-resize-handle--right"
onMouseDown={handleDrawerResizeMouseDown("right")}
onTouchStart={handleDrawerResizeTouchStart("right")}
role="presentation"
aria-hidden="true"
/>
</Show>
<RightDrawerContent />
</Drawer>