From c39e2feda8da0b1e2677ea3fd4e981efcfecb009 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Tue, 11 Aug 2026 03:18:25 +0200 Subject: [PATCH 01/16] feat(app): add Preview tab and Panel Menu to side panel (#162) - Add SESSION_PREVIEW_TAB constant and tab registration in layout-tabs.ts - Add previewOpen signal and preview tab filtering in helpers.ts - Create SessionPreviewTab component: file list with A/M badges, rendered markdown preview via existing Markdown component, raw editor with debounced auto-save and Cmd+S support - Create PanelMenu dropdown component replacing the + button in both v1 and v2 side panel layouts - Wire Preview tab trigger and content in both layout paths - Tab only shows when .md files have been AI-modified in the session - Export new components from session/index.ts Remaining work: - Add /file/write server endpoint for raw editor saves - Remove SESSION_OPEN_FILE_TAB and related dead code paths - Test end-to-end in the running app --- packages/app/src/components/session/index.ts | 2 + .../app/src/components/session/panel-menu.tsx | 73 +++++ .../session/session-preview-tab.tsx | 273 ++++++++++++++++++ packages/app/src/context/layout-tabs.ts | 3 +- packages/app/src/pages/session/helpers.ts | 12 +- .../src/pages/session/session-side-panel.tsx | 161 ++++++++--- 6 files changed, 480 insertions(+), 44 deletions(-) create mode 100644 packages/app/src/components/session/panel-menu.tsx create mode 100644 packages/app/src/components/session/session-preview-tab.tsx diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index c4fdb2ff3..92b194974 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,5 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" +export { SessionPreviewTab } from "./session-preview-tab" +export { PanelMenu } from "./panel-menu" export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" diff --git a/packages/app/src/components/session/panel-menu.tsx b/packages/app/src/components/session/panel-menu.tsx new file mode 100644 index 000000000..ba8b903e3 --- /dev/null +++ b/packages/app/src/components/session/panel-menu.tsx @@ -0,0 +1,73 @@ +import { For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PanelMenuItem { + id: string + label: string + icon: string + available: () => boolean + active?: () => boolean +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function PanelMenu(props: { + items: PanelMenuItem[] + onSelect: (id: string) => void + openFileKeybind?: () => string[][] + v2?: boolean +}) { + return ( + + + + } + > + } + variant="ghost-muted" + size="large" + aria-label="Open panel" + /> + + + + + item.available())}> + {(item) => ( + props.onSelect(item.id)} + > + + {item.label} + + + + + )} + + + + + ) +} diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx new file mode 100644 index 000000000..c25350c74 --- /dev/null +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -0,0 +1,273 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { Markdown } from "@opencode-ai/session-ui/markdown" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface PreviewFileEntry { + path: string + relativePath: string + basename: string + extension: ".md" + changeType: "added" | "modified" +} + +interface PreviewFileState { + mode: "preview" | "raw" + scrollPosition?: number + unsavedContent?: string +} + +// ─── Main Component ───────────────────────────────────────────────────────── + +export function SessionPreviewTab(props: { diffs: () => Array<{ file: string; status?: string }> }) { + const sdk = useSDK() + const serverSDK = useServerSDK() + const [selectedFile, setSelectedFile] = createSignal(undefined) + const [fileStates, setFileStates] = createStore>({}) + const [fileContent, setFileContent] = createSignal("") + const [loading, setLoading] = createSignal(false) + + // Filter diffs to only .md files + const markdownFiles = createMemo((): PreviewFileEntry[] => { + return props + .diffs() + .filter((d) => d.file.endsWith(".md")) + .map((d) => { + const parts = d.file.split("/") + const basename = parts[parts.length - 1] + // relativePath: strip leading ~/ prefix + const relativePath = d.file.startsWith("~/") ? d.file.slice(2) : d.file + return { + path: d.file, + relativePath, + basename, + extension: ".md" as const, + changeType: (d.status === "added" ? "added" : "modified") as "added" | "modified", + } + }) + }) + + // Load file content when a file is selected + createEffect( + on(selectedFile, (path) => { + if (!path) return + setLoading(true) + + // Resolve the actual filesystem path from the display path + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + sdk() + .client.file.read({ path: fsPath }) + .then((result) => { + const content = result.data + if (content && content.type === "text") { + setFileContent(content.content) + } + }) + .catch(() => { + setFileContent("") + }) + .finally(() => { + setLoading(false) + }) + }), + ) + + const currentMode = createMemo(() => { + const path = selectedFile() + if (!path) return "preview" + return fileStates[path]?.mode ?? "preview" + }) + + const toggleMode = () => { + const path = selectedFile() + if (!path) return + const current = fileStates[path]?.mode ?? "preview" + const next = current === "preview" ? "raw" : "preview" + setFileStates(path, { ...fileStates[path], mode: next }) + } + + const goBack = () => { + setSelectedFile(undefined) + } + + // ─── Raw Editor Save ──────────────────────────────────────────────────── + + let saveTimer: ReturnType | undefined + + const saveFile = (path: string, content: string) => { + const fsPath = path.startsWith("~/") + ? path.replace("~", process.env.HOME ?? "") + : path + + const baseUrl = serverSDK().url + if (!baseUrl) return + + // POST to the file write endpoint + fetch(new URL("/file/write", baseUrl), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: fsPath, content }), + }).catch(() => { + // Silently fail — save is best-effort in the preview + }) + } + + const debouncedSave = (path: string, content: string) => { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => saveFile(path, content), 1000) + } + + const immediateSave = () => { + const path = selectedFile() + if (!path) return + const content = fileStates[path]?.unsavedContent + if (content !== undefined) { + if (saveTimer) clearTimeout(saveTimer) + saveFile(path, content) + setFileStates(path, { ...fileStates[path], unsavedContent: undefined }) + } + } + + const handleRawEdit = (content: string) => { + const path = selectedFile() + if (!path) return + setFileStates(path, { ...fileStates[path], unsavedContent: content }) + setFileContent(content) + debouncedSave(path, content) + } + + onCleanup(() => { + if (saveTimer) clearTimeout(saveTimer) + }) + + // ─── Render ───────────────────────────────────────────────────────────── + + return ( +
+ } + > + {(path) => ( +
+ {/* Header with back button and mode toggle */} +
+ +
+ {markdownFiles().find((f) => f.path === path())?.basename ?? path()} +
+ +
+ + {/* Content area */} +
+ Loading...
}> + + } + > +
+ +
+
+ +
+
+ )} + + + ) +} + +// ─── File List ────────────────────────────────────────────────────────────── + +function PreviewFileList(props: { files: PreviewFileEntry[]; onSelect: (path: string) => void }) { + return ( +
+ 0} + fallback={ +
+ No markdown files modified in this session +
+ } + > +
+ + {(file) => ( + + )} + +
+
+
+ ) +} + +// ─── Raw Editor ───────────────────────────────────────────────────────────── + +function RawEditor(props: { content: string; onEdit: (content: string) => void; onSave: () => void }) { + let textareaRef: HTMLTextAreaElement | undefined + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault() + props.onSave() + } + } + + return ( +