From 6f93f7965f718b69e9ea6c303f9f6f1bf2c13f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 20 Jul 2026 22:53:14 +0200 Subject: [PATCH 1/7] feat(ui): load right panel plugin manifests Add a typed manifest loader for right-panel plugins so bundled modules can contribute tabs and Status sections through the registry introduced by the stacked customization PR. The loader runs deterministic onLoad/onUnload lifecycle hooks, skips duplicate or failed manifests without blocking other plugins, and keeps the plugin list explicit for now to avoid arbitrary code loading or marketplace behavior in this step. Validated with focused manifest and registry tests, UI typecheck, whitespace check, UI build, and a final gatekeeper pass. --- .../instance/shell/right-panel/RightPanel.tsx | 7 ++ .../shell/right-panel/plugin-manifest.test.ts | 53 +++++++++++++ .../shell/right-panel/plugin-manifest.ts | 74 +++++++++++++++++++ .../instance/shell/right-panel/plugins.ts | 3 + 4 files changed, 137 insertions(+) create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugins.ts diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 64f195471..3816cf5b7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -80,6 +80,8 @@ import { type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" +import { loadRightPanelPluginManifests } from "./plugin-manifest" +import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) @@ -152,6 +154,10 @@ const RightPanel: Component = (props) => { const [rightPanelCustomization, setRightPanelCustomization] = createSignal( parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) + const rightPanelPluginRuntime = loadRightPanelPluginManifests(RIGHT_PANEL_PLUGIN_MANIFESTS, { instanceId: props.instanceId }) + onCleanup(() => { + rightPanelPluginRuntime.unload() + }) const [browserPath, setBrowserPath] = createSignal(".") const [browserEntries, setBrowserEntries] = createSignal(null) @@ -848,6 +854,7 @@ const RightPanel: Component = (props) => { }, ], }, + ...rightPanelPluginRuntime.modules, ]) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts new file mode 100644 index 000000000..f31d508c6 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { loadRightPanelPluginManifests, type RightPanelPluginManifest } from "./plugin-manifest" + +const manifest = (id: string, events: string[]): RightPanelPluginManifest => ({ + id, + tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }], + lifecycle: { + onLoad: (context) => { + events.push(`${id}:load:${context.instanceId}`) + return () => events.push(`${id}:cleanup`) + }, + onUnload: () => events.push(`${id}:unload`), + }, +}) + +describe("right panel plugin manifests", () => { + it("loads modules and unloads lifecycle hooks in reverse order", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], { instanceId: "abc" }) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"]) + assert.deepEqual(events, ["first:load:abc", "second:load:abc"]) + assert.deepEqual(runtime.unload(), []) + assert.deepEqual(events, ["first:load:abc", "second:load:abc", "second:cleanup", "second:unload", "first:cleanup", "first:unload"]) + }) + + it("skips duplicate ids without blocking other plugins", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], { + instanceId: "abc", + }) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "plugin") + }) + + it("skips plugins that fail during load", () => { + const runtime = loadRightPanelPluginManifests( + [ + { id: "bad", lifecycle: { onLoad: () => { throw new Error("boom") } } }, + { id: "good", tabs: [{ id: "good-tab", labelKey: "good", order: 10, render: () => undefined as any }] }, + ], + { instanceId: "abc" }, + ) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "bad") + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts new file mode 100644 index 000000000..b6d7b2938 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts @@ -0,0 +1,74 @@ +import type { RightPanelModule, RightPanelSectionModule, RightPanelTabModule } from "./registry" + +export type RightPanelPluginCleanup = () => void + +export interface RightPanelPluginContext { + instanceId: string +} + +export interface RightPanelPluginLifecycle { + onLoad?: (context: RightPanelPluginContext) => void | RightPanelPluginCleanup + onUnload?: (context: RightPanelPluginContext) => void +} + +export interface RightPanelPluginManifest { + id: string + tabs?: readonly RightPanelTabModule[] + statusSections?: readonly RightPanelSectionModule[] + lifecycle?: RightPanelPluginLifecycle +} + +export interface RightPanelPluginLoadError { + pluginId: string + phase: "load" | "unload" + error: unknown +} + +export interface LoadedRightPanelPlugins { + modules: RightPanelModule[] + errors: RightPanelPluginLoadError[] + unload: () => RightPanelPluginLoadError[] +} + +export function loadRightPanelPluginManifests( + manifests: readonly RightPanelPluginManifest[], + context: RightPanelPluginContext, +): LoadedRightPanelPlugins { + const modules: RightPanelModule[] = [] + const cleanupStack: { manifest: RightPanelPluginManifest; cleanup?: RightPanelPluginCleanup }[] = [] + const errors: RightPanelPluginLoadError[] = [] + const seen = new Set() + + for (const manifest of manifests) { + if (!manifest.id || seen.has(manifest.id)) { + errors.push({ pluginId: manifest.id || "", phase: "load", error: new Error("Duplicate or missing right panel plugin id") }) + continue + } + seen.add(manifest.id) + + try { + const cleanup = manifest.lifecycle?.onLoad?.(context) + modules.push({ id: manifest.id, tabs: manifest.tabs, statusSections: manifest.statusSections }) + cleanupStack.push({ manifest, cleanup: typeof cleanup === "function" ? cleanup : undefined }) + } catch (error) { + errors.push({ pluginId: manifest.id, phase: "load", error }) + } + } + + return { + modules, + errors, + unload: () => { + const unloadErrors: RightPanelPluginLoadError[] = [] + for (const { manifest, cleanup } of cleanupStack.slice().reverse()) { + try { + cleanup?.() + manifest.lifecycle?.onUnload?.(context) + } catch (error) { + unloadErrors.push({ pluginId: manifest.id, phase: "unload", error }) + } + } + return unloadErrors + }, + } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugins.ts b/packages/ui/src/components/instance/shell/right-panel/plugins.ts new file mode 100644 index 000000000..bda52c731 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugins.ts @@ -0,0 +1,3 @@ +import type { RightPanelPluginManifest } from "./plugin-manifest" + +export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelPluginManifest[] = [] From e50cf1763f805616b72054393ef4b7d850f3c489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 28 Jul 2026 13:50:28 +0200 Subject: [PATCH 2/7] refactor(ui): define native right panel modules as manifests Route the built-in Git Changes, Files, Status tab definitions and native Status sections through core manifest factories. The render functions still close over the existing RightPanel and StatusTab state, so this proves the manifest contract without widening plugin access to private panel internals or changing user-visible behavior. Validation: npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts; npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui; git diff --check. --- .../instance/shell/right-panel/RightPanel.tsx | 242 +++++++++--------- .../shell/right-panel/core-plugin.tsx | 102 ++++++++ .../shell/right-panel/plugin-manifest.test.ts | 30 +++ .../shell/right-panel/tabs/StatusTab.tsx | 66 +---- 4 files changed, 259 insertions(+), 181 deletions(-) create mode 100644 packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 3816cf5b7..45b83e5ef 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -76,10 +76,10 @@ import { parseRightPanelCustomization, setRightPanelItemHidden, type RightPanelCustomization, - type RightPanelModule, type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" +import { createCoreRightPanelManifest } from "./core-plugin" import { loadRightPanelPluginManifests } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" @@ -154,10 +154,6 @@ const RightPanel: Component = (props) => { const [rightPanelCustomization, setRightPanelCustomization] = createSignal( parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) - const rightPanelPluginRuntime = loadRightPanelPluginManifests(RIGHT_PANEL_PLUGIN_MANIFESTS, { instanceId: props.instanceId }) - onCleanup(() => { - rightPanelPluginRuntime.unload() - }) const [browserPath, setBrowserPath] = createSignal(".") const [browserEntries, setBrowserEntries] = createSignal(null) @@ -733,129 +729,119 @@ const RightPanel: Component = (props) => { moveTab(String(draggable.id), String(droppable.id)) } - const rightPanelModules = createMemo(() => [ - { - id: "core-right-panel", - tabs: [ - { - id: "git-changes", - labelKey: "instanceShell.rightPanel.tabs.gitChanges", - order: 10, - render: () => ( - void refreshGitStatus()} - onInsertContext={insertGitChangeContext} - onStageFile={stageGitFile} - onUnstageFile={unstageGitFile} - commitMessage={gitCommitMessage} - commitSubmitting={gitCommitSubmitting} - onCommitMessageInput={setGitCommitMessage} - onSubmitCommit={() => void submitGitCommit()} - branchLabel={gitChangesBranchLabel} - stagedOpen={gitStagedOpen} - unstagedOpen={gitUnstagedOpen} - onToggleStagedOpen={() => { - const next = !gitStagedOpen() - setGitStagedOpen(next) - persistGitSectionOpen("staged", next) - }} - onToggleUnstagedOpen={() => { - const next = !gitUnstagedOpen() - setGitUnstagedOpen(next) - persistGitSectionOpen("unstaged", next) - }} - listOpen={gitChangesListOpen} - onToggleList={toggleGitList} - splitWidth={gitChangesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} - onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - }, - { - id: "files", - labelKey: "instanceShell.rightPanel.tabs.files", - order: 20, - render: () => ( - void loadBrowserEntries(path)} - onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} - onRefresh={() => void refreshFilesTab()} - onSave={(content: string) => void saveBrowserFile(content)} - onContentChange={(content: string) => handleBrowserFileChange(content)} - onWordWrapModeChange={setFilesWordWrapMode} - listOpen={filesListOpen} - onToggleList={toggleFilesList} - splitWidth={filesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("files")} - onResizeTouchStart={handleSplitResizeTouchStart("files")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - }, - { - id: "status", - labelKey: "instanceShell.rightPanel.tabs.status", - order: 30, - render: () => ( - - ), - }, - ], - }, - ...rightPanelPluginRuntime.modules, - ]) + const rightPanelPluginRuntime = loadRightPanelPluginManifests( + [ + createCoreRightPanelManifest({ + renderGitChangesTab: () => ( + void refreshGitStatus()} + onInsertContext={insertGitChangeContext} + onStageFile={stageGitFile} + onUnstageFile={unstageGitFile} + commitMessage={gitCommitMessage} + commitSubmitting={gitCommitSubmitting} + onCommitMessageInput={setGitCommitMessage} + onSubmitCommit={() => void submitGitCommit()} + branchLabel={gitChangesBranchLabel} + stagedOpen={gitStagedOpen} + unstagedOpen={gitUnstagedOpen} + onToggleStagedOpen={() => { + const next = !gitStagedOpen() + setGitStagedOpen(next) + persistGitSectionOpen("staged", next) + }} + onToggleUnstagedOpen={() => { + const next = !gitUnstagedOpen() + setGitUnstagedOpen(next) + persistGitSectionOpen("unstaged", next) + }} + listOpen={gitChangesListOpen} + onToggleList={toggleGitList} + splitWidth={gitChangesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} + onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} + isPhoneLayout={props.isPhoneLayout} + /> + ), + renderFilesTab: () => ( + void loadBrowserEntries(path)} + onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} + onRefresh={() => void refreshFilesTab()} + onSave={(content: string) => void saveBrowserFile(content)} + onContentChange={(content: string) => handleBrowserFileChange(content)} + onWordWrapModeChange={setFilesWordWrapMode} + listOpen={filesListOpen} + onToggleList={toggleFilesList} + splitWidth={filesSplitWidth} + onResizeMouseDown={handleSplitResizeMouseDown("files")} + onResizeTouchStart={handleSplitResizeTouchStart("files")} + isPhoneLayout={props.isPhoneLayout} + /> + ), + renderStatusTab: () => ( + + ), + }), + ...RIGHT_PANEL_PLUGIN_MANIFESTS, + ], + { instanceId: props.instanceId }, + ) + onCleanup(() => { + rightPanelPluginRuntime.unload() + }) + + const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) const visibleRightPanelTabs = createMemo(() => diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx new file mode 100644 index 000000000..d78f6a966 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -0,0 +1,102 @@ +import type { JSX } from "solid-js" + +import type { RightPanelPluginManifest } from "./plugin-manifest" + +interface CoreRightPanelRenderers { + renderGitChangesTab: () => JSX.Element + renderFilesTab: () => JSX.Element + renderStatusTab: () => JSX.Element +} + +interface CoreStatusSectionRenderers { + renderYoloModeSection: () => JSX.Element + renderProviderUsage: () => JSX.Element + renderPlanSectionContent: () => JSX.Element + renderBackgroundProcesses: () => JSX.Element + renderMcpStatus: () => JSX.Element + renderLspStatus: () => JSX.Element + renderPluginStatus: () => JSX.Element +} + +export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelPluginManifest { + return { + id: "core-right-panel", + tabs: [ + { + id: "git-changes", + labelKey: "instanceShell.rightPanel.tabs.gitChanges", + order: 10, + render: renderers.renderGitChangesTab, + }, + { + id: "files", + labelKey: "instanceShell.rightPanel.tabs.files", + order: 20, + render: renderers.renderFilesTab, + }, + { + id: "status", + labelKey: "instanceShell.rightPanel.tabs.status", + order: 30, + render: renderers.renderStatusTab, + }, + ], + } +} + +export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelPluginManifest { + return { + id: "core-status-sections", + statusSections: [ + { + id: "yolo-mode", + labelKey: "instanceShell.rightPanel.sections.yoloMode", + tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", + order: 10, + render: renderers.renderYoloModeSection, + }, + { + id: "provider-usage", + labelKey: "providerUsage.title", + tooltipKey: "providerUsage.tooltip", + order: 20, + render: renderers.renderProviderUsage, + }, + { + id: "plan", + labelKey: "instanceShell.rightPanel.sections.plan", + tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", + order: 30, + render: renderers.renderPlanSectionContent, + }, + { + id: "background-processes", + labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", + tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", + order: 40, + render: renderers.renderBackgroundProcesses, + }, + { + id: "mcp", + labelKey: "instanceShell.rightPanel.sections.mcp", + tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", + order: 50, + render: renderers.renderMcpStatus, + }, + { + id: "lsp", + labelKey: "instanceShell.rightPanel.sections.lsp", + tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", + order: 60, + render: renderers.renderLspStatus, + }, + { + id: "plugins", + labelKey: "instanceShell.rightPanel.sections.plugins", + tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", + order: 70, + render: renderers.renderPluginStatus, + }, + ], + } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index f31d508c6..c32332ed5 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { createCoreRightPanelManifest, createCoreStatusSectionManifest } from "./core-plugin" import { loadRightPanelPluginManifests, type RightPanelPluginManifest } from "./plugin-manifest" const manifest = (id: string, events: string[]): RightPanelPluginManifest => ({ @@ -50,4 +51,33 @@ describe("right panel plugin manifests", () => { assert.equal(runtime.errors.length, 1) assert.equal(runtime.errors[0]?.pluginId, "bad") }) + + it("defines core right panel tabs and status sections as manifests", () => { + const render = () => undefined as any + const rightPanel = createCoreRightPanelManifest({ + renderGitChangesTab: render, + renderFilesTab: render, + renderStatusTab: render, + }) + const statusSections = createCoreStatusSectionManifest({ + renderYoloModeSection: render, + renderProviderUsage: render, + renderPlanSectionContent: render, + renderBackgroundProcesses: render, + renderMcpStatus: render, + renderLspStatus: render, + renderPluginStatus: render, + }) + + assert.deepEqual(rightPanel.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [ + "yolo-mode", + "provider-usage", + "plan", + "background-processes", + "mcp", + "lsp", + "plugins", + ]) + }) }) diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 84baefd15..2c6f8299e 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -25,6 +25,7 @@ import InstanceServiceStatus from "../../../../instance-service-status" import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances" import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" +import { createCoreStatusSectionManifest } from "../core-plugin" interface StatusTabProps { t: (key: string, vars?: Record) => string @@ -228,59 +229,18 @@ const StatusTab: Component = (props) => { } const statusSections = createMemo(() => { - const sections: RightPanelSectionModule[] = [ - { - id: "yolo-mode", - labelKey: "instanceShell.rightPanel.sections.yoloMode", - tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", - order: 10, - render: renderYoloModeSection, - }, - { - id: "provider-usage", - labelKey: "providerUsage.title", - tooltipKey: "providerUsage.tooltip", - order: 20, - render: renderProviderUsage, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", - order: 30, - render: renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", - order: 40, - render: renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", - order: 50, - render: () => , - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", - order: 60, - render: () => , - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", - order: 70, - render: () => ( - - ), - }, - ] + const sections = createCoreStatusSectionManifest({ + renderYoloModeSection, + renderProviderUsage, + renderPlanSectionContent, + renderBackgroundProcesses, + renderMcpStatus: () => , + renderLspStatus: () => , + renderPluginStatus: () => ( + + ), + }).statusSections ?? [] + return applyRightPanelItemCustomization( [...sections, ...(props.extraSections ?? [])], props.customization().statusSectionOrder, From d74be3e77abab0ece4380ee29ada010ceaac8dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 28 Jul 2026 14:14:33 +0200 Subject: [PATCH 3/7] fix(ui): reuse status section metadata in core manifest Derive the native Status tab manifest from CORE_STATUS_SECTION_ITEMS instead of duplicating ids, labels, tooltips, and ordering in core-plugin.tsx. This keeps the customization defaults and plugin manifest source aligned. Missing renderers now fail when building the core manifest so future section additions are caught by the existing manifest test. Validation: npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts; npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run typecheck --workspace @codenomad/ui; npm run build --workspace @codenomad/ui; git diff --check --- .../shell/right-panel/core-plugin.tsx | 67 +++++-------------- 1 file changed, 16 insertions(+), 51 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index d78f6a966..bcd6a9c39 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -1,6 +1,7 @@ import type { JSX } from "solid-js" import type { RightPanelPluginManifest } from "./plugin-manifest" +import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" interface CoreRightPanelRenderers { renderGitChangesTab: () => JSX.Element @@ -45,58 +46,22 @@ export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers) } export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelPluginManifest { + const sectionRenderers: Record JSX.Element> = { + "yolo-mode": renderers.renderYoloModeSection, + "provider-usage": renderers.renderProviderUsage, + plan: renderers.renderPlanSectionContent, + "background-processes": renderers.renderBackgroundProcesses, + mcp: renderers.renderMcpStatus, + lsp: renderers.renderLspStatus, + plugins: renderers.renderPluginStatus, + } + return { id: "core-status-sections", - statusSections: [ - { - id: "yolo-mode", - labelKey: "instanceShell.rightPanel.sections.yoloMode", - tooltipKey: "instanceShell.rightPanel.sections.yoloMode.tooltip", - order: 10, - render: renderers.renderYoloModeSection, - }, - { - id: "provider-usage", - labelKey: "providerUsage.title", - tooltipKey: "providerUsage.tooltip", - order: 20, - render: renderers.renderProviderUsage, - }, - { - id: "plan", - labelKey: "instanceShell.rightPanel.sections.plan", - tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip", - order: 30, - render: renderers.renderPlanSectionContent, - }, - { - id: "background-processes", - labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", - tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip", - order: 40, - render: renderers.renderBackgroundProcesses, - }, - { - id: "mcp", - labelKey: "instanceShell.rightPanel.sections.mcp", - tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip", - order: 50, - render: renderers.renderMcpStatus, - }, - { - id: "lsp", - labelKey: "instanceShell.rightPanel.sections.lsp", - tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip", - order: 60, - render: renderers.renderLspStatus, - }, - { - id: "plugins", - labelKey: "instanceShell.rightPanel.sections.plugins", - tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip", - order: 70, - render: renderers.renderPluginStatus, - }, - ], + statusSections: CORE_STATUS_SECTION_ITEMS.map((section) => { + const render = sectionRenderers[section.id] + if (!render) throw new Error(`Missing core right panel section renderer: ${section.id}`) + return { ...section, render } + }), } } From 4aa93a4099319b940a5d9b59d376b40bc62a1aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 28 Jul 2026 15:49:36 +0200 Subject: [PATCH 4/7] refactor(ui): move native right panel runtime out of shell Turn RightPanel.tsx back into the shell for tab chrome, customization, drag ordering, and active manifest rendering. Native Git, Files, and Status wiring now lives in core-runtime.tsx, with Files state and split resizing isolated in focused runtime helpers. This makes the manifest extraction real instead of wrapping a monolith: plugin loading stays in the shell while native tab state is kept behind internal runtime modules, preserving persisted tab, word-wrap, list-open, section-open, and split-width behavior. Validation: npm run typecheck --workspace @codenomad/ui; npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts; npm exec --no -- tsx --test packages/ui/src/components/instance/shell/right-panel/registry.test.ts; npm run build --workspace @codenomad/ui; git diff --check --- .../instance/shell/right-panel/RightPanel.tsx | 721 +----------------- .../shell/right-panel/core-runtime.tsx | 245 ++++++ .../shell/right-panel/tabs/files-runtime.tsx | 316 ++++++++ .../shell/right-panel/tabs/split-resize.ts | 98 +++ 4 files changed, 684 insertions(+), 696 deletions(-) create mode 100644 packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx create mode 100644 packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 45b83e5ef..d41db33c3 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -1,17 +1,5 @@ -import { - For, - Show, - Suspense, - createEffect, - createMemo, - createSignal, - lazy, - onCleanup, - type Accessor, - type Component, -} from "solid-js" +import { For, Show, Suspense, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import { DragDropProvider, DragDropSensors, @@ -31,45 +19,10 @@ import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { Session } from "../../../../types/session" import type { PromptInputApi } from "../../../prompt-input/types" import type { DrawerViewState } from "../types" -import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" +import type { RightPanelTab } from "./types" -import { - getDefaultWorktreeSlug, - getGitRepoStatus, - getWorktreeSlugForSession, - getWorktrees, -} from "../../../../stores/worktrees" -import { getRootClient } from "../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" -import { requestData } from "../../../../lib/opencode-api" -import { serverApi } from "../../../../lib/api-client" -import { showConfirmDialog } from "../../../../stores/alerts" -import { showToastNotification } from "../../../../lib/notifications" import { readClientLayoutValue, writeClientLayoutValue } from "../../../../stores/client-state" -import { useGlobalPointerDrag } from "../useGlobalPointerDrag" -import { useGitChanges } from "./useGitChanges" -import { - RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, - RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, - RIGHT_PANEL_FILES_WORD_WRAP_KEY, - RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, - RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, - RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, - RIGHT_PANEL_TAB_STORAGE_KEY, - readStoredBool, - readStoredEnum, - readStoredPanelWidth, - readStoredRightPanelTab, -} from "../storage" +import { RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY, readStoredRightPanelTab } from "../storage" import { applyRightPanelItemCustomization, collectRightPanelItems, @@ -79,15 +32,11 @@ import { type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" -import { createCoreRightPanelManifest } from "./core-plugin" +import { createCoreRightPanelRuntime } from "./core-runtime" import { loadRightPanelPluginManifests } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" -const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) -const LazyFilesTab = lazy(() => import("./tabs/FilesTab")) -const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) - function RightPanelTabFallback() { return
} @@ -155,549 +104,10 @@ const RightPanel: Component = (props) => { parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) - const [browserPath, setBrowserPath] = createSignal(".") - const [browserEntries, setBrowserEntries] = createSignal(null) - const [browserLoading, setBrowserLoading] = createSignal(false) - const [browserError, setBrowserError] = createSignal(null) - const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) - const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) - const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) - const [browserSelectedError, setBrowserSelectedError] = createSignal(null) - const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false) - const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false) - const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null) - - const [diffViewMode, setDiffViewMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", - ) - const [diffContextMode, setDiffContextMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", - ) - const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( - readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", - ) - const [filesWordWrapMode, setFilesWordWrapMode] = createSignal( - readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off", - ) - - const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) - const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320) - const [activeSplitResize, setActiveSplitResize] = createSignal<"git-changes" | "files" | null>(null) - const [splitResizeStartX, setSplitResizeStartX] = createSignal(0) - const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0) - - const [filesListOpen, setFilesListOpen] = createSignal(true) - const [filesListTouched, setFilesListTouched] = createSignal(false) - const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) - const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) - const [gitStagedOpen, setGitStagedOpen] = createSignal(true) - const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) - - const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) - - const listOpenStorageKey = (tab: "git-changes" | "files") => { - const layout = listLayoutKey() - if (tab === "git-changes") { - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY - } - return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY - } - - const gitSectionStorageKey = (section: "staged" | "unstaged") => { - const layout = listLayoutKey() - if (section === "staged") { - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY - } - return layout === "phone" - ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY - : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY - } - - const persistListOpen = (tab: "git-changes" | "files", value: boolean) => { - writeClientLayoutValue(listOpenStorageKey(tab), value ? "true" : "false") - } - - const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { - writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false") - } - - createEffect(() => { - // Refresh persisted visibility when layout changes (phone vs non-phone). - const layout = listLayoutKey() - layout - - const filesPersisted = readStoredBool(listOpenStorageKey("files")) - if (filesPersisted !== null) { - setFilesListOpen(filesPersisted) - setFilesListTouched(true) - } else { - setFilesListOpen(true) - setFilesListTouched(false) - } - - const gitPersisted = readStoredBool(listOpenStorageKey("git-changes")) - if (gitPersisted !== null) { - setGitChangesListOpen(gitPersisted) - setGitChangesListTouched(true) - } else { - setGitChangesListOpen(true) - setGitChangesListTouched(false) - } - - const stagedPersisted = readStoredBool(gitSectionStorageKey("staged")) - setGitStagedOpen(stagedPersisted ?? true) - - const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged")) - setGitUnstagedOpen(unstagedPersisted ?? true) - }) - - createEffect(() => { - // Default behavior: when nothing is selected, keep the file list open. - // Once the user explicitly toggles it, we stop auto-opening. - if (rightPanelTab() !== "files") return - if (filesListTouched()) return - if (!browserSelectedPath()) { - setFilesListOpen(true) - } - }) - createEffect(() => { writeClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) }) - createEffect(() => { - writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode()) - }) - - createEffect(() => { - writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) - }) - - createEffect(() => { - writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode()) - }) - - createEffect(() => { - writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode()) - }) - - const clampSplitWidth = (value: number) => { - const min = 200 - const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) - const max = Math.min(560, maxByDrawer) - return Math.min(max, Math.max(min, Math.floor(value))) - } - - const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false) - - createEffect(() => { - if (splitWidthsInitialized()) return - if (!props.rightDrawerWidthInitialized()) return - setSplitWidthsInitialized(true) - setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320))) - setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320))) - }) - - const persistSplitWidth = (mode: "git-changes" | "files", width: number) => { - const key = mode === "git-changes" ? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY : RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY - writeClientLayoutValue(key, String(width)) - } - - function stopSplitResize() { - setActiveSplitResize(null) - if (typeof document === "undefined") return - splitPointerDrag.stop() - } - - function splitMouseMove(event: MouseEvent) { - const mode = activeSplitResize() - if (!mode) return - event.preventDefault() - const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" - const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) - const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitMouseUp() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - function splitTouchMove(event: TouchEvent) { - const mode = activeSplitResize() - if (!mode) return - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" - const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1) - const next = clampSplitWidth(splitResizeStartWidth() + delta) - if (mode === "git-changes") setGitChangesSplitWidth(next) - else setFilesSplitWidth(next) - } - - function splitTouchEnd() { - const mode = activeSplitResize() - if (mode) { - const width = mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth() - persistSplitWidth(mode, width) - } - stopSplitResize() - } - - const splitPointerDrag = useGlobalPointerDrag({ - onMouseMove: splitMouseMove, - onMouseUp: splitMouseUp, - onTouchMove: splitTouchMove, - onTouchEnd: splitTouchEnd, - }) - - const startSplitResize = (mode: "git-changes" | "files", clientX: number) => { - if (typeof document === "undefined") return - setActiveSplitResize(mode) - setSplitResizeStartX(clientX) - setSplitResizeStartWidth(mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()) - splitPointerDrag.start() - } - - const handleSplitResizeMouseDown = (mode: "git-changes" | "files") => (event: MouseEvent) => { - event.preventDefault() - startSplitResize(mode, event.clientX) - } - - const handleSplitResizeTouchStart = (mode: "git-changes" | "files") => (event: TouchEvent) => { - const touch = event.touches[0] - if (!touch) return - event.preventDefault() - startSplitResize(mode, touch.clientX) - } - - onCleanup(() => { - stopSplitResize() - }) - - const worktreeSlugForViewer = createMemo(() => { - const sessionId = props.activeSessionId() - if (sessionId && sessionId !== "info") { - return getWorktreeSlugForSession(props.instanceId, sessionId) - } - return getDefaultWorktreeSlug(props.instanceId) - }) - - const gitChangesWorktreeSlug = createMemo(() => { - if (getGitRepoStatus(props.instanceId) === false) return null - const slug = worktreeSlugForViewer().trim() - return slug ? slug : null - }) - - const gitChangesWorktree = createMemo(() => { - const slug = gitChangesWorktreeSlug() - if (!slug) return null - return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null - }) - - const gitChangesBranchLabel = createMemo(() => { - const branch = gitChangesWorktree()?.branch?.trim() - return branch || null - }) - - const browserClient = createMemo(() => getRootClient(props.instanceId)) - const fileWorkspacePayload = async () => { - const workspace = await getOpenCodeWorkspaceIdForWorktree(props.instanceId, worktreeSlugForViewer()) - return workspace ? { workspace } : {} - } - - const { - gitStatusEntries, - gitStatusLoading, - gitStatusError, - gitSelectedItemId, - gitBulkSelectedItemIds, - gitSelectedLoading, - gitSelectedError, - gitSelectedBefore, - gitSelectedAfter, - gitCommitMessage, - gitCommitSubmitting, - gitMostChangedItemId, - setGitCommitMessage, - handleGitRowClick, - refreshGitStatus, - insertGitChangeContext, - submitGitCommit, - stageGitFile, - unstageGitFile, - } = useGitChanges({ - t: props.t, - instanceId: props.instanceId, - rightPanelTab, - worktreeSlug: worktreeSlugForViewer, - isPhoneLayout: props.isPhoneLayout, - promptInputApi: props.promptInputApi, - closeGitList: () => setGitChangesListOpen(false), - }) - - createEffect(() => { - worktreeSlugForViewer() - setBrowserPath(".") - setBrowserEntries(null) - setBrowserError(null) - setBrowserSelectedPath(null) - setBrowserSelectedContent(null) - setBrowserSelectedError(null) - setBrowserSelectedLoading(false) - }) - - const normalizeBrowserPath = (input: string) => { - const raw = String(input || ".").trim() - if (!raw || raw === "./") return "." - const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") - return cleaned === "" ? "." : cleaned - } - - const getParentPath = (path: string): string | null => { - const current = normalizeBrowserPath(path) - if (current === ".") return null - const parts = current.split("/").filter(Boolean) - parts.pop() - return parts.length ? parts.join("/") : "." - } - - const loadBrowserEntries = async (path: string) => { - const normalized = normalizeBrowserPath(path) - setBrowserLoading(true) - setBrowserError(null) - try { - const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") - setBrowserPath(normalized) - setBrowserEntries(Array.isArray(nodes) ? nodes : []) - } catch (error) { - setBrowserError(error instanceof Error ? error.message : "Failed to load files") - setBrowserEntries([]) - } finally { - setBrowserLoading(false) - } - } - - const openBrowserFile = async (path: string) => { - setBrowserSelectedPath(path) - setBrowserSelectedLoading(true) - setBrowserSelectedError(null) - setBrowserSelectedContent(null) - setBrowserSelectedDirty(false) - setBrowserSelectedOriginalContent(null) - - // Phone: treat file selection as a commit action and close the overlay. - if (props.isPhoneLayout()) { - setFilesListOpen(false) - } - try { - const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const text = (content as any)?.content - if (typeof text !== "string") { - throw new Error("Unsupported file type") - } - setBrowserSelectedContent(text) - setBrowserSelectedOriginalContent(text) // Track original content for conflict detection - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - - const saveBrowserFile = async (content: string): Promise => { - const path = browserSelectedPath() - if (!path) return false - - // Check for conflict: agent edited file while user was editing - const originalContent = browserSelectedOriginalContent() - if (originalContent !== null) { - try { - const currentDiskContent = await requestData( - browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), - "file.read", - ) - const diskContent = (currentDiskContent as any)?.content - - // If disk content differs from what we originally loaded (agent edit) - // AND differs from user's current edits, we have a conflict - if (diskContent !== originalContent && diskContent !== content) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.conflict.message", { path }), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), - dismissible: false, - }, - ) - if (!confirmed) { - return false - } - // User chose to overwrite, proceed with save - } - } catch { - // If we can't check for conflict, proceed with save - } - } - - setBrowserSelectedSaving(true) - try { - await serverApi.writeWorkspaceFile(props.instanceId, path, content, { worktree: worktreeSlugForViewer() }) - setBrowserSelectedContent(content) - setBrowserSelectedOriginalContent(content) // Update original to match saved - setBrowserSelectedDirty(false) - showToastNotification({ - message: props.t("instanceShell.rightPanel.toast.saveSuccess"), - variant: "success", - }) - return true - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file") - showToastNotification({ - message: props.t("instanceShell.rightPanel.toast.saveError"), - variant: "error", - }) - return false - } finally { - setBrowserSelectedSaving(false) - } - } - - const handleBrowserFileChange = (content: string) => { - setBrowserSelectedContent(content) - setBrowserSelectedDirty(true) - } - - const handleOpenBrowserFileRequest = async (path: string) => { - if (browserSelectedDirty()) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"), - dismissible: false, - }, - ) - if (confirmed) { - const saveSuccess = await saveBrowserFile(browserSelectedContent() || "") - if (!saveSuccess) { - // Save failed - stay on current file, error toast already shown - return - } - } else { - // User chose not to save - clear dirty state and discard edits - setBrowserSelectedDirty(false) - } - } - await openBrowserFile(path) - } - - createEffect(() => { - if (rightPanelTab() !== "files") return - if (browserLoading()) return - if (browserEntries() !== null) return - void loadBrowserEntries(browserPath()) - }) - - createEffect(() => { - if (rightPanelTab() === "files") return - setBrowserSelectedContent(null) - setBrowserSelectedLoading(false) - setBrowserSelectedError(null) - setBrowserSelectedDirty(false) - }) - - const toggleFilesList = () => { - setFilesListTouched(true) - setFilesListOpen((current) => { - const next = !current - persistListOpen("files", next) - return next - }) - } - - const toggleGitList = () => { - setGitChangesListTouched(true) - setGitChangesListOpen((current) => { - const next = !current - persistListOpen("git-changes", next) - return next - }) - } - - const refreshFilesTab = async () => { - // Prompt for confirmation if file has unsaved changes - if (browserSelectedDirty()) { - const confirmed = await showConfirmDialog( - props.t("instanceShell.rightPanel.actions.refreshDirty.message"), - { - variant: "warning", - confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), - cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"), - dismissible: false, - }, - ) - if (!confirmed) { - return - } - } - - void loadBrowserEntries(browserPath()) - const selected = browserSelectedPath() - if (selected) { - // Refresh file content without altering overlay state. - setBrowserSelectedLoading(true) - setBrowserSelectedError(null) - try { - const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") - const type = (content as any)?.type - const encoding = (content as any)?.encoding - if (type && type !== "text") { - throw new Error("Binary file cannot be displayed") - } - if (encoding === "base64") { - throw new Error("Binary file cannot be displayed") - } - const text = (content as any)?.content - if (typeof text !== "string") { - throw new Error("Unsupported file type") - } - setBrowserSelectedContent(text) - setBrowserSelectedOriginalContent(text) // Update original content after refresh - setBrowserSelectedDirty(false) // Clear dirty after refresh - } catch (error) { - setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") - } finally { - setBrowserSelectedLoading(false) - } - } - } - - const browserParentPath = createMemo(() => getParentPath(browserPath())) - const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`) - const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`) - const handleAccordionChange = (values: string[]) => { setRightPanelExpandedItems(values) } @@ -731,107 +141,27 @@ const RightPanel: Component = (props) => { const rightPanelPluginRuntime = loadRightPanelPluginManifests( [ - createCoreRightPanelManifest({ - renderGitChangesTab: () => ( - void refreshGitStatus()} - onInsertContext={insertGitChangeContext} - onStageFile={stageGitFile} - onUnstageFile={unstageGitFile} - commitMessage={gitCommitMessage} - commitSubmitting={gitCommitSubmitting} - onCommitMessageInput={setGitCommitMessage} - onSubmitCommit={() => void submitGitCommit()} - branchLabel={gitChangesBranchLabel} - stagedOpen={gitStagedOpen} - unstagedOpen={gitUnstagedOpen} - onToggleStagedOpen={() => { - const next = !gitStagedOpen() - setGitStagedOpen(next) - persistGitSectionOpen("staged", next) - }} - onToggleUnstagedOpen={() => { - const next = !gitUnstagedOpen() - setGitUnstagedOpen(next) - persistGitSectionOpen("unstaged", next) - }} - listOpen={gitChangesListOpen} - onToggleList={toggleGitList} - splitWidth={gitChangesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} - onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - renderFilesTab: () => ( - void loadBrowserEntries(path)} - onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} - onRefresh={() => void refreshFilesTab()} - onSave={(content: string) => void saveBrowserFile(content)} - onContentChange={(content: string) => handleBrowserFileChange(content)} - onWordWrapModeChange={setFilesWordWrapMode} - listOpen={filesListOpen} - onToggleList={toggleFilesList} - splitWidth={filesSplitWidth} - onResizeMouseDown={handleSplitResizeMouseDown("files")} - onResizeTouchStart={handleSplitResizeTouchStart("files")} - isPhoneLayout={props.isPhoneLayout} - /> - ), - renderStatusTab: () => ( - - ), + createCoreRightPanelRuntime({ + t: props.t, + instanceId: props.instanceId, + instance: props.instance, + activeSessionId: props.activeSessionId, + activeSession: props.activeSession, + latestTodoState: props.latestTodoState, + backgroundProcessList: props.backgroundProcessList, + onOpenBackgroundOutput: props.onOpenBackgroundOutput, + onStopBackgroundProcess: props.onStopBackgroundProcess, + onTerminateBackgroundProcess: props.onTerminateBackgroundProcess, + isPhoneLayout: props.isPhoneLayout, + rightDrawerWidth: props.rightDrawerWidth, + rightDrawerWidthInitialized: props.rightDrawerWidthInitialized, + promptInputApi: props.promptInputApi, + rightPanelTab, + expandedItems: rightPanelExpandedItems, + onExpandedItemsChange: handleAccordionChange, + customization: rightPanelCustomization, + onCustomizationChange: updateRightPanelCustomization, + extraStatusSections: () => extraStatusSections(), }), ...RIGHT_PANEL_PLUGIN_MANIFESTS, ], @@ -842,7 +172,6 @@ const RightPanel: Component = (props) => { }) const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules) - const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) const visibleRightPanelTabs = createMemo(() => applyRightPanelItemCustomization( diff --git a/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx new file mode 100644 index 000000000..c7be8ecce --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/core-runtime.tsx @@ -0,0 +1,245 @@ +import { createEffect, createMemo, createSignal, lazy, type Accessor } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk/v2" + +import type { Instance } from "../../../../types/instance" +import type { BackgroundProcess } from "../../../../../../server/src/api-types" +import type { Session } from "../../../../types/session" +import type { PromptInputApi } from "../../../prompt-input/types" +import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" +import type { RightPanelCustomization, RightPanelSectionModule } from "./registry" + +import { + getDefaultWorktreeSlug, + getGitRepoStatus, + getWorktreeSlugForSession, + getWorktrees, +} from "../../../../stores/worktrees" +import { writeClientLayoutValue } from "../../../../stores/client-state" +import { + RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, + RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY, + RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY, + readStoredBool, + readStoredEnum, +} from "../storage" +import { useGitChanges } from "./useGitChanges" +import { createCoreRightPanelManifest } from "./core-plugin" +import { createFilesTabRuntime } from "./tabs/files-runtime" +import { createSplitResize } from "./tabs/split-resize" + +const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) +const LazyStatusTab = lazy(() => import("./tabs/StatusTab")) + +interface CoreRightPanelRuntimeOptions { + t: (key: string, vars?: Record) => string + instanceId: string + instance: Instance + activeSessionId: Accessor + activeSession: Accessor + latestTodoState: Accessor + backgroundProcessList: Accessor + onOpenBackgroundOutput: (process: BackgroundProcess) => void + onStopBackgroundProcess: (processId: string) => Promise | void + onTerminateBackgroundProcess: (processId: string) => Promise | void + isPhoneLayout: Accessor + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor + promptInputApi: Accessor + rightPanelTab: Accessor + expandedItems: Accessor + onExpandedItemsChange: (values: string[]) => void + customization: Accessor + onCustomizationChange: (updater: (current: RightPanelCustomization) => RightPanelCustomization) => void + extraStatusSections: Accessor +} + +export function createCoreRightPanelRuntime(options: CoreRightPanelRuntimeOptions) { + const [diffViewMode, setDiffViewMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified", + ) + const [diffContextMode, setDiffContextMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", + ) + const [diffWordWrapMode, setDiffWordWrapMode] = createSignal( + readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on", + ) + const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) + const [gitStagedOpen, setGitStagedOpen] = createSignal(true) + const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true) + + const listLayoutKey = createMemo(() => (options.isPhoneLayout() ? "phone" : "nonphone")) + + const gitListOpenStorageKey = createMemo(() => + listLayoutKey() === "phone" ? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, + ) + + const gitSectionStorageKey = (section: "staged" | "unstaged") => { + const phone = listLayoutKey() === "phone" + if (section === "staged") { + return phone ? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY + } + return phone ? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY : RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY + } + + createEffect(() => { + gitListOpenStorageKey() + const gitPersisted = readStoredBool(gitListOpenStorageKey()) + if (gitPersisted !== null) { + setGitChangesListOpen(gitPersisted) + } else { + setGitChangesListOpen(true) + } + + setGitStagedOpen(readStoredBool(gitSectionStorageKey("staged")) ?? true) + setGitUnstagedOpen(readStoredBool(gitSectionStorageKey("unstaged")) ?? true) + }) + + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())) + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())) + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())) + + const gitChangesSplit = createSplitResize({ + storageKey: RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, + defaultWidth: 320, + rightDrawerWidth: options.rightDrawerWidth, + rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, + }) + + const worktreeSlugForViewer = createMemo(() => { + const sessionId = options.activeSessionId() + if (sessionId && sessionId !== "info") { + return getWorktreeSlugForSession(options.instanceId, sessionId) + } + return getDefaultWorktreeSlug(options.instanceId) + }) + + const gitChangesWorktreeSlug = createMemo(() => { + if (getGitRepoStatus(options.instanceId) === false) return null + const slug = worktreeSlugForViewer().trim() + return slug ? slug : null + }) + + const gitChangesWorktree = createMemo(() => { + const slug = gitChangesWorktreeSlug() + if (!slug) return null + return getWorktrees(options.instanceId).find((worktree) => worktree.slug === slug) ?? null + }) + + const gitChangesBranchLabel = createMemo(() => gitChangesWorktree()?.branch?.trim() || null) + const gitScopeKey = createMemo(() => `${options.instanceId}:git:${worktreeSlugForViewer()}`) + const git = useGitChanges({ + t: options.t, + instanceId: options.instanceId, + rightPanelTab: options.rightPanelTab, + worktreeSlug: worktreeSlugForViewer, + isPhoneLayout: options.isPhoneLayout, + promptInputApi: options.promptInputApi, + closeGitList: () => setGitChangesListOpen(false), + }) + const renderFilesTab = createFilesTabRuntime({ + t: options.t, + instanceId: options.instanceId, + rightPanelTab: options.rightPanelTab, + worktreeSlug: worktreeSlugForViewer, + isPhoneLayout: options.isPhoneLayout, + rightDrawerWidth: options.rightDrawerWidth, + rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, + }) + + const persistGitListOpen = (value: boolean) => { + writeClientLayoutValue(gitListOpenStorageKey(), value ? "true" : "false") + } + + const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => { + writeClientLayoutValue(gitSectionStorageKey(section), value ? "true" : "false") + } + + const toggleGitList = () => { + setGitChangesListOpen((current) => { + const next = !current + persistGitListOpen(next) + return next + }) + } + + return createCoreRightPanelManifest({ + renderGitChangesTab: () => ( + void git.refreshGitStatus()} + onInsertContext={git.insertGitChangeContext} + onStageFile={git.stageGitFile} + onUnstageFile={git.unstageGitFile} + commitMessage={git.gitCommitMessage} + commitSubmitting={git.gitCommitSubmitting} + onCommitMessageInput={git.setGitCommitMessage} + onSubmitCommit={() => void git.submitGitCommit()} + branchLabel={gitChangesBranchLabel} + stagedOpen={gitStagedOpen} + unstagedOpen={gitUnstagedOpen} + onToggleStagedOpen={() => { + const next = !gitStagedOpen() + setGitStagedOpen(next) + persistGitSectionOpen("staged", next) + }} + onToggleUnstagedOpen={() => { + const next = !gitUnstagedOpen() + setGitUnstagedOpen(next) + persistGitSectionOpen("unstaged", next) + }} + listOpen={gitChangesListOpen} + onToggleList={toggleGitList} + splitWidth={gitChangesSplit.splitWidth} + onResizeMouseDown={gitChangesSplit.onResizeMouseDown} + onResizeTouchStart={gitChangesSplit.onResizeTouchStart} + isPhoneLayout={options.isPhoneLayout} + /> + ), + renderFilesTab, + renderStatusTab: () => ( + + ), + }) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx new file mode 100644 index 000000000..462b20a1e --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -0,0 +1,316 @@ +import { createEffect, createMemo, createSignal, lazy, type Accessor, type JSX } from "solid-js" +import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" + +import type { DiffWordWrapMode, RightPanelTab } from "../types" + +import { getRootClient } from "../../../../../stores/opencode-client" +import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces" +import { requestData } from "../../../../../lib/opencode-api" +import { serverApi } from "../../../../../lib/api-client" +import { showConfirmDialog } from "../../../../../stores/alerts" +import { showToastNotification } from "../../../../../lib/notifications" +import { writeClientLayoutValue } from "../../../../../stores/client-state" +import { + RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY, + RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + RIGHT_PANEL_FILES_WORD_WRAP_KEY, + readStoredBool, + readStoredEnum, +} from "../../storage" +import { createSplitResize } from "./split-resize" + +const LazyFilesTab = lazy(() => import("./FilesTab")) + +interface FilesTabRuntimeOptions { + t: (key: string, vars?: Record) => string + instanceId: string + rightPanelTab: Accessor + worktreeSlug: Accessor + isPhoneLayout: Accessor + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor +} + +export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JSX.Element { + const [browserPath, setBrowserPath] = createSignal(".") + const [browserEntries, setBrowserEntries] = createSignal(null) + const [browserLoading, setBrowserLoading] = createSignal(false) + const [browserError, setBrowserError] = createSignal(null) + const [browserSelectedPath, setBrowserSelectedPath] = createSignal(null) + const [browserSelectedContent, setBrowserSelectedContent] = createSignal(null) + const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false) + const [browserSelectedError, setBrowserSelectedError] = createSignal(null) + const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false) + const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false) + const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal(null) + const [filesWordWrapMode, setFilesWordWrapMode] = createSignal( + readStoredEnum(RIGHT_PANEL_FILES_WORD_WRAP_KEY, ["on", "off"] as const) ?? "off", + ) + const [filesListOpen, setFilesListOpen] = createSignal(true) + const [filesListTouched, setFilesListTouched] = createSignal(false) + const browserClient = createMemo(() => getRootClient(options.instanceId)) + const filesSplit = createSplitResize({ + storageKey: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, + defaultWidth: 320, + rightDrawerWidth: options.rightDrawerWidth, + rightDrawerWidthInitialized: options.rightDrawerWidthInitialized, + }) + + const filesListOpenStorageKey = createMemo(() => + options.isPhoneLayout() ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY, + ) + + const fileWorkspacePayload = async () => { + const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug()) + return workspace ? { workspace } : {} + } + + createEffect(() => { + filesListOpenStorageKey() + const persisted = readStoredBool(filesListOpenStorageKey()) + if (persisted !== null) { + setFilesListOpen(persisted) + setFilesListTouched(true) + } else { + setFilesListOpen(true) + setFilesListTouched(false) + } + }) + + createEffect(() => { + if (options.rightPanelTab() !== "files") return + if (filesListTouched()) return + if (!browserSelectedPath()) setFilesListOpen(true) + }) + + createEffect(() => writeClientLayoutValue(RIGHT_PANEL_FILES_WORD_WRAP_KEY, filesWordWrapMode())) + + createEffect(() => { + options.worktreeSlug() + setBrowserPath(".") + setBrowserEntries(null) + setBrowserError(null) + setBrowserSelectedPath(null) + setBrowserSelectedContent(null) + setBrowserSelectedError(null) + setBrowserSelectedLoading(false) + }) + + const normalizeBrowserPath = (input: string) => { + const raw = String(input || ".").trim() + if (!raw || raw === "./") return "." + const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "") + return cleaned === "" ? "." : cleaned + } + + const getParentPath = (path: string): string | null => { + const current = normalizeBrowserPath(path) + if (current === ".") return null + const parts = current.split("/").filter(Boolean) + parts.pop() + return parts.length ? parts.join("/") : "." + } + + const loadBrowserEntries = async (path: string) => { + const normalized = normalizeBrowserPath(path) + setBrowserLoading(true) + setBrowserError(null) + try { + const nodes = await requestData(browserClient().file.list({ path: normalized, ...(await fileWorkspacePayload()) }), "file.list") + setBrowserPath(normalized) + setBrowserEntries(Array.isArray(nodes) ? nodes : []) + } catch (error) { + setBrowserError(error instanceof Error ? error.message : "Failed to load files") + setBrowserEntries([]) + } finally { + setBrowserLoading(false) + } + } + + const openBrowserFile = async (path: string) => { + setBrowserSelectedPath(path) + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + setBrowserSelectedContent(null) + setBrowserSelectedDirty(false) + setBrowserSelectedOriginalContent(null) + + if (options.isPhoneLayout()) setFilesListOpen(false) + try { + const content = await requestData(browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") throw new Error("Binary file cannot be displayed") + if (encoding === "base64") throw new Error("Binary file cannot be displayed") + const text = (content as any)?.content + if (typeof text !== "string") throw new Error("Unsupported file type") + setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + const saveBrowserFile = async (content: string): Promise => { + const path = browserSelectedPath() + if (!path) return false + + const originalContent = browserSelectedOriginalContent() + if (originalContent !== null) { + try { + const currentDiskContent = await requestData( + browserClient().file.read({ path, ...(await fileWorkspacePayload()) }), + "file.read", + ) + const diskContent = (currentDiskContent as any)?.content + if (diskContent !== originalContent && diskContent !== content) { + const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.conflict.message", { path }), { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.conflict.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.conflict.cancelLabel"), + dismissible: false, + }) + if (!confirmed) return false + } + } catch { + // If conflict detection fails, keep the existing behavior and try the save. + } + } + + setBrowserSelectedSaving(true) + try { + await serverApi.writeWorkspaceFile(options.instanceId, path, content, { worktree: options.worktreeSlug() }) + setBrowserSelectedContent(content) + setBrowserSelectedOriginalContent(content) + setBrowserSelectedDirty(false) + showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveSuccess"), variant: "success" }) + return true + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file") + showToastNotification({ message: options.t("instanceShell.rightPanel.toast.saveError"), variant: "error" }) + return false + } finally { + setBrowserSelectedSaving(false) + } + } + + const handleOpenBrowserFileRequest = async (path: string) => { + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog( + options.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }), + { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"), + dismissible: false, + }, + ) + if (confirmed) { + const saveSuccess = await saveBrowserFile(browserSelectedContent() || "") + if (!saveSuccess) return + } else { + setBrowserSelectedDirty(false) + } + } + await openBrowserFile(path) + } + + createEffect(() => { + if (options.rightPanelTab() !== "files") return + if (browserLoading()) return + if (browserEntries() !== null) return + void loadBrowserEntries(browserPath()) + }) + + createEffect(() => { + if (options.rightPanelTab() === "files") return + setBrowserSelectedContent(null) + setBrowserSelectedLoading(false) + setBrowserSelectedError(null) + setBrowserSelectedDirty(false) + }) + + const toggleFilesList = () => { + setFilesListTouched(true) + setFilesListOpen((current) => { + const next = !current + writeClientLayoutValue(filesListOpenStorageKey(), next ? "true" : "false") + return next + }) + } + + const refreshFilesTab = async () => { + if (browserSelectedDirty()) { + const confirmed = await showConfirmDialog(options.t("instanceShell.rightPanel.actions.refreshDirty.message"), { + variant: "warning", + confirmLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"), + cancelLabel: options.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"), + dismissible: false, + }) + if (!confirmed) return + } + + void loadBrowserEntries(browserPath()) + const selected = browserSelectedPath() + if (!selected) return + + setBrowserSelectedLoading(true) + setBrowserSelectedError(null) + try { + const content = await requestData(browserClient().file.read({ path: selected, ...(await fileWorkspacePayload()) }), "file.read") + const type = (content as any)?.type + const encoding = (content as any)?.encoding + if (type && type !== "text") throw new Error("Binary file cannot be displayed") + if (encoding === "base64") throw new Error("Binary file cannot be displayed") + const text = (content as any)?.content + if (typeof text !== "string") throw new Error("Unsupported file type") + setBrowserSelectedContent(text) + setBrowserSelectedOriginalContent(text) + setBrowserSelectedDirty(false) + } catch (error) { + setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file") + } finally { + setBrowserSelectedLoading(false) + } + } + + const browserParentPath = createMemo(() => getParentPath(browserPath())) + const browserScopeKey = createMemo(() => `${options.instanceId}:${options.worktreeSlug()}`) + + return () => ( + void loadBrowserEntries(path)} + onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)} + onRefresh={() => void refreshFilesTab()} + onSave={(content: string) => void saveBrowserFile(content)} + onContentChange={(content: string) => { + setBrowserSelectedContent(content) + setBrowserSelectedDirty(true) + }} + onWordWrapModeChange={setFilesWordWrapMode} + listOpen={filesListOpen} + onToggleList={toggleFilesList} + splitWidth={filesSplit.splitWidth} + onResizeMouseDown={filesSplit.onResizeMouseDown} + onResizeTouchStart={filesSplit.onResizeTouchStart} + isPhoneLayout={options.isPhoneLayout} + /> + ) +} diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts new file mode 100644 index 000000000..8f3a8cad7 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/split-resize.ts @@ -0,0 +1,98 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" + +import { writeClientLayoutValue } from "../../../../../stores/client-state" +import { readStoredPanelWidth } from "../../storage" +import { useGlobalPointerDrag } from "../../useGlobalPointerDrag" + +interface SplitResizeOptions { + storageKey: string + defaultWidth: number + rightDrawerWidth: Accessor + rightDrawerWidthInitialized: Accessor +} + +export function createSplitResize(options: SplitResizeOptions) { + const [splitWidth, setSplitWidth] = createSignal(options.defaultWidth) + const [initialized, setInitialized] = createSignal(false) + const [active, setActive] = createSignal(false) + const [startX, setStartX] = createSignal(0) + const [startWidth, setStartWidth] = createSignal(0) + + const clampSplitWidth = (value: number) => { + const min = 200 + const maxByDrawer = Math.max(min, Math.floor(options.rightDrawerWidth() * 0.65)) + const max = Math.min(560, maxByDrawer) + return Math.min(max, Math.max(min, Math.floor(value))) + } + + createEffect(() => { + if (initialized()) return + if (!options.rightDrawerWidthInitialized()) return + setInitialized(true) + setSplitWidth(clampSplitWidth(readStoredPanelWidth(options.storageKey, options.defaultWidth))) + }) + + const persistSplitWidth = () => { + writeClientLayoutValue(options.storageKey, String(splitWidth())) + } + + function stopResize() { + setActive(false) + if (typeof document === "undefined") return + pointerDrag.stop() + } + + function move(clientX: number) { + if (!active()) return + const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl" + const delta = (clientX - startX()) * (isRtl ? -1 : 1) + setSplitWidth(clampSplitWidth(startWidth() + delta)) + } + + const pointerDrag = useGlobalPointerDrag({ + onMouseMove: (event) => { + if (!active()) return + event.preventDefault() + move(event.clientX) + }, + onMouseUp: () => { + if (active()) persistSplitWidth() + stopResize() + }, + onTouchMove: (event) => { + if (!active()) return + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + move(touch.clientX) + }, + onTouchEnd: () => { + if (active()) persistSplitWidth() + stopResize() + }, + }) + + const startResize = (clientX: number) => { + if (typeof document === "undefined") return + setActive(true) + setStartX(clientX) + setStartWidth(splitWidth()) + pointerDrag.start() + } + + onCleanup(stopResize) + + return { + splitWidth, + onResizeMouseDown: (event: MouseEvent) => { + event.preventDefault() + startResize(event.clientX) + }, + onResizeTouchStart: (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + event.preventDefault() + startResize(touch.clientX) + }, + } +} From 1db1bce83767ce853d2b584de6a9e9d2babee3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 31 Jul 2026 23:56:10 +0200 Subject: [PATCH 5/7] refactor(ui): align right panel modules with workflow host Replace static right panel plugin declarations with first-party manifest factories that receive a small host context for instance, session, i18n, tab activation, tab opening, and future attention reporting. Restore the tab accessibility behavior needed by the Workflows follow-up by wiring tab ids, aria-controls, tab panels, roving tabIndex, and Arrow/Home/End keyboard navigation into the extracted right panel shell. Keep Status as a special always-visible tab, move status section customization into StatusTab itself, and surface module identity plus create failures in the customization UI so bundled modules do not disappear silently. Validated with UI typecheck, targeted right-panel tests, git diff --check, and the UI build. The build still emits the existing large chunk warning. --- .../instance/shell/right-panel/RightPanel.tsx | 184 +++++++++++------- .../shell/right-panel/core-plugin.tsx | 60 +++--- .../shell/right-panel/plugin-manifest.test.ts | 52 +++-- .../shell/right-panel/plugin-manifest.ts | 75 +++---- .../instance/shell/right-panel/plugins.ts | 4 +- .../shell/right-panel/registry.test.ts | 8 +- .../instance/shell/right-panel/registry.ts | 3 + .../shell/right-panel/tabs/StatusTab.tsx | 69 ++++++- .../ui/src/lib/i18n/messages/de/instance.ts | 10 +- .../ui/src/lib/i18n/messages/en/instance.ts | 10 +- .../ui/src/lib/i18n/messages/es/instance.ts | 10 +- .../ui/src/lib/i18n/messages/fr/instance.ts | 10 +- .../ui/src/lib/i18n/messages/he/instance.ts | 10 +- .../ui/src/lib/i18n/messages/ja/instance.ts | 10 +- .../ui/src/lib/i18n/messages/ne/instance.ts | 10 +- .../ui/src/lib/i18n/messages/ru/instance.ts | 10 +- .../src/lib/i18n/messages/zh-Hans/instance.ts | 10 +- 17 files changed, 360 insertions(+), 185 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index d41db33c3..19f4e3ef0 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -1,4 +1,4 @@ -import { For, Show, Suspense, createEffect, createMemo, createSignal, onCleanup, type Accessor, type Component } from "solid-js" +import { For, Show, Suspense, createEffect, createMemo, createSignal, createUniqueId, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import { DragDropProvider, @@ -33,7 +33,7 @@ import { type RightPanelTabModule, } from "./registry" import { createCoreRightPanelRuntime } from "./core-runtime" -import { loadRightPanelPluginManifests } from "./plugin-manifest" +import { loadRightPanelPluginManifests, type RightPanelPluginLoadError } from "./plugin-manifest" import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" @@ -44,9 +44,13 @@ function RightPanelTabFallback() { interface SortableRightPanelTabProps { tab: RightPanelTabModule active: boolean + tabId: string + panelId: string label: string dragTitle: string + tabIndex: number onSelect: () => void + onKeyDown: (event: KeyboardEvent) => void } const SortableRightPanelTab: Component = (props) => { @@ -56,10 +60,14 @@ const SortableRightPanelTab: Component = (props) => @@ -103,6 +111,9 @@ const RightPanel: Component = (props) => { const [rightPanelCustomization, setRightPanelCustomization] = createSignal( parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) + const tabGroupId = `right-panel-${createUniqueId()}` + const tabId = (id: string) => `${tabGroupId}-tab-${id}` + const tabPanelId = (id: string) => `${tabGroupId}-panel-${id}` createEffect(() => { writeClientLayoutValue(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab()) @@ -139,6 +150,31 @@ const RightPanel: Component = (props) => { moveTab(String(draggable.id), String(droppable.id)) } + const openRightPanelTab = (tabId: string) => { + updateRightPanelCustomization((current) => ({ + ...current, + hiddenTabIds: current.hiddenTabIds.filter((id) => id !== tabId), + })) + setRightPanelTab(tabId) + } + + const handleTabKeyDown = (event: KeyboardEvent, currentTabId: string) => { + const tabs = visibleRightPanelTabs() + const index = tabs.findIndex((tab) => tab.id === currentTabId) + if (index === -1) return + + let target: RightPanelTabModule | undefined + if (event.key === "ArrowLeft") target = tabs[(index - 1 + tabs.length) % tabs.length] + if (event.key === "ArrowRight") target = tabs[(index + 1) % tabs.length] + if (event.key === "Home") target = tabs[0] + if (event.key === "End") target = tabs[tabs.length - 1] + if (!target) return + + event.preventDefault() + setRightPanelTab(target.id) + queueMicrotask(() => document.getElementById(tabId(target.id))?.focus()) + } + const rightPanelPluginRuntime = loadRightPanelPluginManifests( [ createCoreRightPanelRuntime({ @@ -165,13 +201,18 @@ const RightPanel: Component = (props) => { }), ...RIGHT_PANEL_PLUGIN_MANIFESTS, ], - { instanceId: props.instanceId }, + { + instanceId: props.instanceId, + t: props.t, + activeSessionId: props.activeSessionId, + isTabActive: (tabId) => rightPanelTab() === tabId, + openTab: openRightPanelTab, + reportAttention: () => undefined, + }, ) - onCleanup(() => { - rightPanelPluginRuntime.unload() - }) const rightPanelModules = createMemo(() => rightPanelPluginRuntime.modules) + const rightPanelPluginErrors = createMemo(() => rightPanelPluginRuntime.errors) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) const visibleRightPanelTabs = createMemo(() => applyRightPanelItemCustomization( @@ -182,15 +223,6 @@ const RightPanel: Component = (props) => { ) const orderedRightPanelTabs = createMemo(() => applyRightPanelItemCustomization(allRightPanelTabs(), rightPanelCustomization().tabOrder, [])) const extraStatusSections = createMemo(() => collectRightPanelItems(rightPanelModules(), "statusSections")) - const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()]) - const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), rightPanelCustomization().statusSectionOrder, [])) - const visibleStatusSections = createMemo(() => - applyRightPanelItemCustomization( - allStatusSections(), - rightPanelCustomization().statusSectionOrder, - rightPanelCustomization().hiddenStatusSectionIds, - ), - ) const activeRightPanelTab = createMemo(() => visibleRightPanelTabs().find((tab) => tab.id === rightPanelTab()) ?? visibleRightPanelTabs()[0]) createEffect(() => { @@ -248,9 +280,13 @@ const RightPanel: Component = (props) => { setRightPanelTab(tab.id)} + onKeyDown={(event) => handleTabKeyDown(event, tab.id)} /> )} @@ -282,67 +318,65 @@ const RightPanel: Component = (props) => {
-
-
{props.t("instanceShell.rightPanel.customize.tabs")}
- - {(tab) => { - const label = () => props.t(tab.labelKey) - const visible = () => !rightPanelCustomization().hiddenTabIds.includes(tab.id) - const disableHide = () => visible() && visibleRightPanelTabs().length <= 1 - return ( -
- + + {(module) => { + const moduleTabs = () => orderedRightPanelTabs().filter((tab) => module.tabs?.some((entry) => entry.id === tab.id)) + return ( + 0}> +
+
{props.t(module.displayNameKey)}
+ + {(descriptionKey) =>
{props.t(descriptionKey())}
} +
+ + {(tab) => { + const label = () => props.t(tab.labelKey) + const visible = () => tab.alwaysVisible || !rightPanelCustomization().hiddenTabIds.includes(tab.id) + return ( +
+ + + {props.t("instanceShell.rightPanel.customize.alwaysVisible")} + +
+ ) + }} +
- ) - }} -
-
+ + ) + }} +
-
-
{props.t("instanceShell.rightPanel.customize.statusSections")}
- - {(section) => { - const label = () => props.t(section.labelKey) - const visible = () => !rightPanelCustomization().hiddenStatusSectionIds.includes(section.id) - const disableHide = () => visible() && visibleStatusSections().length <= 1 - return ( + 0}> +
+
{props.t("instanceShell.rightPanel.customize.unavailableModules")}
+ + {(error: RightPanelPluginLoadError) => (
- + + {props.t("instanceShell.rightPanel.customize.moduleUnavailable", { + module: error.displayNameKey ? props.t(error.displayNameKey) : error.pluginId, + })} +
- ) - }} -
-
+ )} +
+
+
{props.t("instanceShell.rightPanel.customize.dragToReorder")}
@@ -350,7 +384,11 @@ const RightPanel: Component = (props) => {
- {(tab) => }>{tab.render()}} + {(tab) => ( +
+ }>{tab.render()} +
+ )}
diff --git a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx index bcd6a9c39..20fd18c77 100644 --- a/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/core-plugin.tsx @@ -1,6 +1,7 @@ import type { JSX } from "solid-js" -import type { RightPanelPluginManifest } from "./plugin-manifest" +import type { RightPanelManifest } from "./plugin-manifest" +import type { RightPanelModule } from "./registry" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" interface CoreRightPanelRenderers { @@ -19,33 +20,43 @@ interface CoreStatusSectionRenderers { renderPluginStatus: () => JSX.Element } -export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelPluginManifest { +export function createCoreRightPanelManifest(renderers: CoreRightPanelRenderers): RightPanelManifest { return { id: "core-right-panel", - tabs: [ - { - id: "git-changes", - labelKey: "instanceShell.rightPanel.tabs.gitChanges", - order: 10, - render: renderers.renderGitChangesTab, - }, - { - id: "files", - labelKey: "instanceShell.rightPanel.tabs.files", - order: 20, - render: renderers.renderFilesTab, - }, - { - id: "status", - labelKey: "instanceShell.rightPanel.tabs.status", - order: 30, - render: renderers.renderStatusTab, - }, - ], + displayNameKey: "instanceShell.rightPanel.modules.core", + descriptionKey: "instanceShell.rightPanel.modules.core.description", + origin: "first-party", + create: () => ({ + id: "core-right-panel", + displayNameKey: "instanceShell.rightPanel.modules.core", + descriptionKey: "instanceShell.rightPanel.modules.core.description", + origin: "first-party", + tabs: [ + { + id: "git-changes", + labelKey: "instanceShell.rightPanel.tabs.gitChanges", + order: 10, + render: renderers.renderGitChangesTab, + }, + { + id: "files", + labelKey: "instanceShell.rightPanel.tabs.files", + order: 20, + render: renderers.renderFilesTab, + }, + { + id: "status", + labelKey: "instanceShell.rightPanel.tabs.status", + order: 30, + alwaysVisible: true, + render: renderers.renderStatusTab, + }, + ], + }), } } -export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelPluginManifest { +export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRenderers): RightPanelModule { const sectionRenderers: Record JSX.Element> = { "yolo-mode": renderers.renderYoloModeSection, "provider-usage": renderers.renderProviderUsage, @@ -58,6 +69,9 @@ export function createCoreStatusSectionManifest(renderers: CoreStatusSectionRend return { id: "core-status-sections", + displayNameKey: "instanceShell.rightPanel.modules.core", + descriptionKey: "instanceShell.rightPanel.modules.core.description", + origin: "first-party", statusSections: CORE_STATUS_SECTION_ITEMS.map((section) => { const render = sectionRenderers[section.id] if (!render) throw new Error(`Missing core right panel section renderer: ${section.id}`) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts index c32332ed5..7aa0f7f58 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -2,36 +2,43 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { createCoreRightPanelManifest, createCoreStatusSectionManifest } from "./core-plugin" -import { loadRightPanelPluginManifests, type RightPanelPluginManifest } from "./plugin-manifest" +import { loadRightPanelPluginManifests, type RightPanelHostContext, type RightPanelManifest } from "./plugin-manifest" -const manifest = (id: string, events: string[]): RightPanelPluginManifest => ({ +const host: RightPanelHostContext = { + instanceId: "abc", + t: (key) => key, + activeSessionId: () => "session-1", + isTabActive: () => false, + openTab: () => {}, +} + +const manifest = (id: string, events: string[]): RightPanelManifest => ({ id, - tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }], - lifecycle: { - onLoad: (context) => { - events.push(`${id}:load:${context.instanceId}`) - return () => events.push(`${id}:cleanup`) - }, - onUnload: () => events.push(`${id}:unload`), + displayNameKey: id, + origin: "first-party", + create: (context) => { + events.push(`${id}:create:${context.instanceId}:${context.activeSessionId()}`) + return { + id, + displayNameKey: id, + origin: "first-party", + tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }], + } }, }) describe("right panel plugin manifests", () => { - it("loads modules and unloads lifecycle hooks in reverse order", () => { + it("creates modules with host context", () => { const events: string[] = [] - const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], { instanceId: "abc" }) + const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], host) assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"]) - assert.deepEqual(events, ["first:load:abc", "second:load:abc"]) - assert.deepEqual(runtime.unload(), []) - assert.deepEqual(events, ["first:load:abc", "second:load:abc", "second:cleanup", "second:unload", "first:cleanup", "first:unload"]) + assert.deepEqual(events, ["first:create:abc:session-1", "second:create:abc:session-1"]) }) it("skips duplicate ids without blocking other plugins", () => { const events: string[] = [] - const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], { - instanceId: "abc", - }) + const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], host) assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"]) assert.equal(runtime.errors.length, 1) @@ -41,10 +48,10 @@ describe("right panel plugin manifests", () => { it("skips plugins that fail during load", () => { const runtime = loadRightPanelPluginManifests( [ - { id: "bad", lifecycle: { onLoad: () => { throw new Error("boom") } } }, - { id: "good", tabs: [{ id: "good-tab", labelKey: "good", order: 10, render: () => undefined as any }] }, + { id: "bad", displayNameKey: "bad", origin: "first-party", create: () => { throw new Error("boom") } }, + manifest("good", []), ], - { instanceId: "abc" }, + host, ) assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"]) @@ -69,7 +76,10 @@ describe("right panel plugin manifests", () => { renderPluginStatus: render, }) - assert.deepEqual(rightPanel.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + const rightPanelModule = rightPanel.create(host) + + assert.deepEqual(rightPanelModule.tabs?.map((entry) => entry.id), ["git-changes", "files", "status"]) + assert.equal(rightPanelModule.tabs?.find((entry) => entry.id === "status")?.alwaysVisible, true) assert.deepEqual(statusSections.statusSections?.map((entry) => entry.id), [ "yolo-mode", "provider-usage", diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts index b6d7b2938..b5337f94d 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts @@ -1,74 +1,75 @@ -import type { RightPanelModule, RightPanelSectionModule, RightPanelTabModule } from "./registry" +import type { Accessor } from "solid-js" +import type { RightPanelModule } from "./registry" -export type RightPanelPluginCleanup = () => void - -export interface RightPanelPluginContext { +export interface RightPanelHostContext { instanceId: string + t: (key: string, vars?: Record) => string + activeSessionId: Accessor + isTabActive: (tabId: string) => boolean + openTab: (tabId: string) => void + reportAttention?: (attention: RightPanelAttention) => void } -export interface RightPanelPluginLifecycle { - onLoad?: (context: RightPanelPluginContext) => void | RightPanelPluginCleanup - onUnload?: (context: RightPanelPluginContext) => void +export interface RightPanelAttention { + moduleId: string + tabId?: string + messageKey: string + severity: "info" | "warning" | "critical" } -export interface RightPanelPluginManifest { +export interface RightPanelManifest { id: string - tabs?: readonly RightPanelTabModule[] - statusSections?: readonly RightPanelSectionModule[] - lifecycle?: RightPanelPluginLifecycle + displayNameKey: string + descriptionKey?: string + origin: "first-party" + create: (host: RightPanelHostContext) => RightPanelModule } export interface RightPanelPluginLoadError { pluginId: string - phase: "load" | "unload" + displayNameKey?: string + phase: "create" error: unknown } export interface LoadedRightPanelPlugins { modules: RightPanelModule[] errors: RightPanelPluginLoadError[] - unload: () => RightPanelPluginLoadError[] } export function loadRightPanelPluginManifests( - manifests: readonly RightPanelPluginManifest[], - context: RightPanelPluginContext, + manifests: readonly RightPanelManifest[], + context: RightPanelHostContext, ): LoadedRightPanelPlugins { const modules: RightPanelModule[] = [] - const cleanupStack: { manifest: RightPanelPluginManifest; cleanup?: RightPanelPluginCleanup }[] = [] const errors: RightPanelPluginLoadError[] = [] const seen = new Set() for (const manifest of manifests) { if (!manifest.id || seen.has(manifest.id)) { - errors.push({ pluginId: manifest.id || "", phase: "load", error: new Error("Duplicate or missing right panel plugin id") }) + errors.push({ + pluginId: manifest.id || "", + displayNameKey: manifest.displayNameKey, + phase: "create", + error: new Error("Duplicate or missing right panel plugin id"), + }) continue } seen.add(manifest.id) try { - const cleanup = manifest.lifecycle?.onLoad?.(context) - modules.push({ id: manifest.id, tabs: manifest.tabs, statusSections: manifest.statusSections }) - cleanupStack.push({ manifest, cleanup: typeof cleanup === "function" ? cleanup : undefined }) + const module = manifest.create(context) + modules.push({ + ...module, + id: manifest.id, + displayNameKey: manifest.displayNameKey, + descriptionKey: manifest.descriptionKey ?? module.descriptionKey, + origin: manifest.origin, + }) } catch (error) { - errors.push({ pluginId: manifest.id, phase: "load", error }) + errors.push({ pluginId: manifest.id, displayNameKey: manifest.displayNameKey, phase: "create", error }) } } - return { - modules, - errors, - unload: () => { - const unloadErrors: RightPanelPluginLoadError[] = [] - for (const { manifest, cleanup } of cleanupStack.slice().reverse()) { - try { - cleanup?.() - manifest.lifecycle?.onUnload?.(context) - } catch (error) { - unloadErrors.push({ pluginId: manifest.id, phase: "unload", error }) - } - } - return unloadErrors - }, - } + return { modules, errors } } diff --git a/packages/ui/src/components/instance/shell/right-panel/plugins.ts b/packages/ui/src/components/instance/shell/right-panel/plugins.ts index bda52c731..372530ecf 100644 --- a/packages/ui/src/components/instance/shell/right-panel/plugins.ts +++ b/packages/ui/src/components/instance/shell/right-panel/plugins.ts @@ -1,3 +1,3 @@ -import type { RightPanelPluginManifest } from "./plugin-manifest" +import type { RightPanelManifest } from "./plugin-manifest" -export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelPluginManifest[] = [] +export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelManifest[] = [] diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts index a7b39d015..8575b053d 100644 --- a/packages/ui/src/components/instance/shell/right-panel/registry.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/registry.test.ts @@ -8,18 +8,20 @@ import { parseRightPanelCustomization, setRightPanelItemHidden, type RightPanelItem, + type RightPanelModule, type RightPanelTabModule, } from "./registry" const item = (id: string, order: number, alwaysVisible = false): RightPanelItem => ({ id, labelKey: id, order, alwaysVisible }) const tab = (id: string, order: number): RightPanelTabModule => ({ ...item(id, order), render: () => undefined as any }) +const module = (id: string, tabs: RightPanelTabModule[]): RightPanelModule => ({ id, displayNameKey: id, origin: "first-party", tabs }) describe("right panel registry", () => { it("collects and orders module items", () => { const items = collectRightPanelItems( [ - { id: "core", tabs: [tab("status", 40), tab("changes", 10)] }, - { id: "plugin", tabs: [tab("custom", 30)] }, + module("core", [tab("status", 40), tab("changes", 10)]), + module("plugin", [tab("custom", 30)]), ], "tabs", ) @@ -28,7 +30,7 @@ describe("right panel registry", () => { }) it("rejects duplicate item ids", () => { - assert.throws(() => collectRightPanelItems([{ id: "core", tabs: [tab("status", 10), tab("status", 20)] }], "tabs")) + assert.throws(() => collectRightPanelItems([module("core", [tab("status", 10), tab("status", 20)])], "tabs")) }) it("applies visibility and user order", () => { diff --git a/packages/ui/src/components/instance/shell/right-panel/registry.ts b/packages/ui/src/components/instance/shell/right-panel/registry.ts index c24077eac..47aba6356 100644 --- a/packages/ui/src/components/instance/shell/right-panel/registry.ts +++ b/packages/ui/src/components/instance/shell/right-panel/registry.ts @@ -19,6 +19,9 @@ export interface RightPanelSectionModule extends RightPanelItem { export interface RightPanelModule { id: string + displayNameKey: string + descriptionKey?: string + origin: "first-party" tabs?: readonly RightPanelTabModule[] statusSections?: readonly RightPanelSectionModule[] } diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index 2c6f8299e..ee3620f07 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type Accessor, type Component } from "solid-js" +import { For, Show, createMemo, createSignal, type Accessor, type Component } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" import { DragDropProvider, @@ -12,7 +12,7 @@ import { Accordion } from "@kobalte/core" import { Tooltip } from "@kobalte/core/tooltip" import Switch from "@suid/material/Switch" -import { BellRing, ChevronDown, GripVertical, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" +import { BellRing, ChevronDown, GripVertical, Info, Settings2, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import type { Instance } from "../../../../../types/instance" import type { BackgroundProcess } from "../../../../../../../server/src/api-types" @@ -24,7 +24,7 @@ import { TodoListView } from "../../../../tool-call/renderers/todo" import InstanceServiceStatus from "../../../../instance-service-status" import { togglePermissionAutoAcceptForSession } from "../../../../../stores/instances" import { isPermissionAutoAcceptEnabled } from "../../../../../stores/permission-auto-accept" -import { applyRightPanelItemCustomization, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" +import { applyRightPanelItemCustomization, setRightPanelItemHidden, type RightPanelCustomization, type RightPanelSectionModule } from "../registry" import { createCoreStatusSectionManifest } from "../core-plugin" interface StatusTabProps { @@ -85,6 +85,7 @@ const SortableStatusSection: Component = (props) => } const StatusTab: Component = (props) => { + const [sectionCustomizationOpen, setSectionCustomizationOpen] = createSignal(false) const isSectionExpanded = (id: string) => props.expandedItems().includes(id) const renderYoloModeSection = () => { @@ -228,7 +229,7 @@ const StatusTab: Component = (props) => { ) } - const statusSections = createMemo(() => { + const allStatusSections = createMemo(() => { const sections = createCoreStatusSectionManifest({ renderYoloModeSection, renderProviderUsage, @@ -241,12 +242,16 @@ const StatusTab: Component = (props) => { ), }).statusSections ?? [] - return applyRightPanelItemCustomization( - [...sections, ...(props.extraSections ?? [])], + return [...sections, ...(props.extraSections ?? [])] + }) + const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), props.customization().statusSectionOrder, [])) + const statusSections = createMemo(() => + applyRightPanelItemCustomization( + allStatusSections(), props.customization().statusSectionOrder, props.customization().hiddenStatusSectionIds, - ) - }) + ), + ) const moveSection = (sourceId: string, targetId: string) => { if (!sourceId || sourceId === targetId) return @@ -273,6 +278,54 @@ const StatusTab: Component = (props) => { )} +
+
{props.t("instanceShell.rightPanel.customize.statusSections")}
+ +
+ + +
+ + {(section) => { + const label = () => props.t(section.labelKey) + const visible = () => !props.customization().hiddenStatusSectionIds.includes(section.id) + const disableHide = () => visible() && statusSections().length <= 1 + return ( +
+ +
+ ) + }} +
+
{props.t("instanceShell.rightPanel.customize.dragToReorder")}
+
+
+ Date: Sat, 1 Aug 2026 02:29:04 +0200 Subject: [PATCH 6/7] fix(ui): simplify right panel customization popovers Remove helper and description copy from the right panel customization surfaces so the popovers only show the controls users need. Move Status section customization behind a compact settings icon in the token counter header, matching the general panel customization affordance while keeping Status itself always available. Validated with UI typecheck, targeted right-panel tests, git diff --check, and the UI build. The build still emits the existing large chunk warning. --- .../instance/shell/right-panel/RightPanel.tsx | 8 ---- .../shell/right-panel/tabs/StatusTab.tsx | 24 +++++----- packages/ui/src/styles/panels/right-panel.css | 47 ++++++++++++++++++- 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 19f4e3ef0..802911a3b 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -306,7 +306,6 @@ const RightPanel: Component = (props) => {
{props.t("instanceShell.rightPanel.customize.title")}
-
{props.t("instanceShell.rightPanel.customize.description")}
-
{props.t("instanceShell.rightPanel.customize.dragToReorder")}
diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx index ee3620f07..4c47cbf60 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/StatusTab.tsx @@ -272,27 +272,26 @@ const StatusTab: Component = (props) => { return (
- - {(activeSession) => ( - - )} - - -
-
{props.t("instanceShell.rightPanel.customize.statusSections")}
+
+ + {(activeSession) => ( + + )} +
-
+ diff --git a/packages/ui/src/styles/panels/right-panel.css b/packages/ui/src/styles/panels/right-panel.css index 1d0bb460c..b7df4cc4e 100644 --- a/packages/ui/src/styles/panels/right-panel.css +++ b/packages/ui/src/styles/panels/right-panel.css @@ -710,12 +710,55 @@ /* Status tab layout */ .status-tab-container { @apply flex flex-col h-full min-h-0; + position: relative; +} + +.status-tab-header { + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 1px solid var(--border-base); + background-color: var(--surface-secondary); + padding-inline-end: 0.5rem; } .status-tab-context-panel { - @apply border-b; - border-color: var(--border-base); + flex: 1 1 auto; + min-width: 0; +} + +.status-tab-customization-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--border-base); + background-color: transparent; + color: var(--text-secondary); +} + +.status-tab-customization-trigger:hover { + background-color: var(--surface-hover); + color: var(--text-primary); +} + +.status-tab-customization-trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color); +} + +.status-tab-customization-popover { + position: absolute; + top: 2.75rem; + inset-inline-end: 0.5rem; + z-index: 20; + width: min(16rem, calc(100% - 1rem)); + border: 1px solid var(--border-base); background-color: var(--surface-secondary); + box-shadow: var(--popover-shadow); + padding: 0.5rem; } /* Accordion improvements for right panel */ From 65e06c1c8e5396f4600ea6b3c94854fc38d62beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sat, 1 Aug 2026 03:41:47 +0200 Subject: [PATCH 7/7] fix(ui): merge right panel customization controls Collapse tab and Status section customization into one minimal right panel popover. Status remains checked and disabled, with its sections shown directly underneath as indented rows. Remove the separate Status customization trigger and obsolete styling so the panel has one settings affordance and a single reset action at the bottom. Validated with UI typecheck, targeted right-panel tests, git diff --check, and the UI build. The build still emits the existing large chunk warning. --- .../instance/shell/right-panel/RightPanel.tsx | 110 +++++++++++------- .../shell/right-panel/tabs/StatusTab.tsx | 62 ++-------- packages/ui/src/styles/panels/right-panel.css | 76 ++---------- 3 files changed, 80 insertions(+), 168 deletions(-) diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 802911a3b..52a48805c 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -29,6 +29,7 @@ import { parseRightPanelCustomization, setRightPanelItemHidden, type RightPanelCustomization, + type RightPanelItem, type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" @@ -223,6 +224,15 @@ const RightPanel: Component = (props) => { ) const orderedRightPanelTabs = createMemo(() => applyRightPanelItemCustomization(allRightPanelTabs(), rightPanelCustomization().tabOrder, [])) const extraStatusSections = createMemo(() => collectRightPanelItems(rightPanelModules(), "statusSections")) + const allStatusSections = createMemo(() => [...CORE_STATUS_SECTION_ITEMS, ...extraStatusSections()]) + const orderedStatusSections = createMemo(() => applyRightPanelItemCustomization(allStatusSections(), rightPanelCustomization().statusSectionOrder, [])) + const visibleStatusSections = createMemo(() => + applyRightPanelItemCustomization( + allStatusSections(), + rightPanelCustomization().statusSectionOrder, + rightPanelCustomization().hiddenStatusSectionIds, + ), + ) const activeRightPanelTab = createMemo(() => visibleRightPanelTabs().find((tab) => tab.id === rightPanelTab()) ?? visibleRightPanelTabs()[0]) createEffect(() => { @@ -303,74 +313,86 @@ const RightPanel: Component = (props) => {