From 8902469359217038dc1e162670c719a836f5c82c Mon Sep 17 00:00:00 2001
From: Hona <10430890+Hona@users.noreply.github.com>
Date: Wed, 16 Sep 2026 03:37:00 +0000
Subject: [PATCH 1/4] feat(app): render markdown files in file view
---
packages/app/e2e/regression/review-open-file.spec.ts | 6 +++++-
packages/app/src/session/files/file-tabs.tsx | 11 ++++++++++-
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts
index 208ba7f3b69d..46c4a25b76a1 100644
--- a/packages/app/e2e/regression/review-open-file.spec.ts
+++ b/packages/app/e2e/regression/review-open-file.spec.ts
@@ -55,7 +55,10 @@ test("opens and searches project files inline", async ({ page }) => {
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
]
},
- fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
+ fileContent: (path) => ({
+ type: "text",
+ content: path === "README.md" ? "# Rendered README\n\ncontents:README.md" : `contents:${path}`,
+ }),
findFiles: (input) => {
searches.push(input)
return input.query === "nested" ? ["src/nested.ts"] : []
@@ -126,6 +129,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("padding-inline-end", "4px")
await expect(panel.getByRole("tab", { name: "README.md" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
+ await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(sidebar).toHaveCount(0)
diff --git a/packages/app/src/session/files/file-tabs.tsx b/packages/app/src/session/files/file-tabs.tsx
index 1446426840e8..4f90bf9548b7 100644
--- a/packages/app/src/session/files/file-tabs.tsx
+++ b/packages/app/src/session/files/file-tabs.tsx
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode/session-ui/file"
+import { Markdown } from "@opencode/session-ui/markdown"
import { useFileComponent } from "@opencode/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { createLineCommentControllerV2 } from "@opencode/session-ui/v2/line-comment-annotations-v2"
@@ -454,7 +455,15 @@ export function SessionFileView(props: SessionFileViewProps) {
- {renderFile(contents())}
+
+ {path()?.toLowerCase().endsWith(".md") ? (
+
+
+
+ ) : (
+ renderFile(contents())
+ )}
+
{language.t("common.loading")}…
From ae7f7245388dd8c635271d220072fb70c996109f Mon Sep 17 00:00:00 2001
From: Brendonovich
Date: Wed, 16 Sep 2026 04:40:57 +0000
Subject: [PATCH 2/4] feat(app): add file view toolbar
---
.../e2e/regression/review-open-file.spec.ts | 10 ++++
packages/app/src/runtime/i18n/en.ts | 3 ++
packages/app/src/session/files/file-tabs.tsx | 50 +++++++++++++++++--
.../src/session/files/open-in-app-button.tsx | 10 ++--
.../src/session/files/session-side-panel.tsx | 4 +-
5 files changed, 67 insertions(+), 10 deletions(-)
diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts
index 46c4a25b76a1..7752d03f556e 100644
--- a/packages/app/e2e/regression/review-open-file.spec.ts
+++ b/packages/app/e2e/regression/review-open-file.spec.ts
@@ -131,6 +131,14 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
+ await expect(panel.getByTitle("README.md")).toBeVisible()
+ const markdownView = panel.getByRole("group", { name: "Markdown view" })
+ await expect(markdownView.getByRole("button", { name: "Rendered" })).toHaveAttribute("aria-pressed", "true")
+ await markdownView.getByRole("button", { name: "Source" }).click()
+ await expect(panel.getByRole("heading", { name: "Rendered README" })).toHaveCount(0)
+ await expect(panel.getByText("# Rendered README", { exact: true })).toBeVisible()
+ await markdownView.getByRole("button", { name: "Rendered" }).click()
+ await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible()
await expect(sidebar).toHaveCount(0)
const missingReadPattern = "**/api/fs/read/README.md*"
@@ -177,6 +185,8 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" }).locator("..")).toHaveCSS("gap", "8px")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
+ await expect(panel.getByTitle("src/nested.ts")).toBeVisible()
+ await expect(panel.getByRole("group", { name: "Markdown view" })).toHaveCount(0)
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index c82892415a7f..5ccb7630154b 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -796,6 +796,9 @@ export const dict = {
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
+ "session.files.markdown.view": "Markdown view",
+ "session.files.markdown.rendered": "Rendered",
+ "session.files.markdown.source": "Source",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
diff --git a/packages/app/src/session/files/file-tabs.tsx b/packages/app/src/session/files/file-tabs.tsx
index 4f90bf9548b7..883b5b3df7a3 100644
--- a/packages/app/src/session/files/file-tabs.tsx
+++ b/packages/app/src/session/files/file-tabs.tsx
@@ -1,15 +1,17 @@
-import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
+import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode/session-ui/file"
import { Markdown } from "@opencode/session-ui/markdown"
import { useFileComponent } from "@opencode/ui/context/file"
+import { FileIcon } from "@opencode/ui/file-icon"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { createLineCommentControllerV2 } from "@opencode/session-ui/v2/line-comment-annotations-v2"
import { sampledChecksum } from "@opencode/util/encode"
import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
+import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
@@ -20,6 +22,9 @@ import { useComposerState } from "@/composer/persistence"
import { getSessionHandoff } from "@/session/handoff"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionTabs } from "@/session/helpers"
+import { OpenInAppButton } from "@/session/files/open-in-app-button"
+import { resolveOpenInAppPath } from "@/session/files/open-in-app-path"
+import { useWorkspaceLocation } from "@/workspaces/location"
type SessionFileViewProps = {
tab: string
@@ -183,6 +188,7 @@ export function SessionFileView(props: SessionFileViewProps) {
const language = useLanguage()
const prompt = useComposerState()
const fileComponent = useFileComponent()
+ const location = useWorkspaceLocation()
const { sessionKey, tabs, view } = useSessionLayout()
const activeFileTab = createSessionTabs({
tabs,
@@ -199,6 +205,9 @@ export function SessionFileView(props: SessionFileViewProps) {
}
const path = createMemo(() => file.pathFromTab(props.tab))
+ const markdown = createMemo(() => path()?.toLowerCase().endsWith(".md") ?? false)
+ const absolutePath = createMemo(() => resolveOpenInAppPath(location().directory, path() ?? ""))
+ const [display, setDisplay] = createStore({ markdown: "rendered" as "rendered" | "source" })
const state = createMemo(() => {
const p = path()
if (!p) return
@@ -452,11 +461,44 @@ export function SessionFileView(props: SessionFileViewProps) {
)
const content = () => (
-
-
+
+
+ {(value) => (
+
+
+
+
+ {value()}
+
+
+
+
+ {
+ if (value !== "rendered" && value !== "source") return
+ setDisplay("markdown", value)
+ }}
+ class="!w-auto"
+ aria-label={language.t("session.files.markdown.view")}
+ >
+
+ {language.t("session.files.markdown.rendered")}
+
+
+ {language.t("session.files.markdown.source")}
+
+
+
+
+
+
+ )}
+
+
- {path()?.toLowerCase().endsWith(".md") ? (
+ {markdown() && display.markdown === "rendered" ? (
diff --git a/packages/app/src/session/files/open-in-app-button.tsx b/packages/app/src/session/files/open-in-app-button.tsx
index b4501ea724b1..4680b2cc802e 100644
--- a/packages/app/src/session/files/open-in-app-button.tsx
+++ b/packages/app/src/session/files/open-in-app-button.tsx
@@ -8,12 +8,12 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
-export function OpenInAppButton(props: { directory: () => string }) {
+export function OpenInAppButton(props: { path: () => string; reveal?: boolean }) {
const language = useLanguage()
- const state = useOpenInApp({ path: props.directory })
+ const state = useOpenInApp({ path: props.path })
return (
-
+
event.stopPropagation()}>
string }) {
onClick={(event) => {
event.stopPropagation()
if (state.opening()) return
- state.openPath(state.current().id)
+ state.openPath(state.current().id, undefined, props.reveal)
}}
disabled={state.opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
@@ -52,7 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx
index ee2723007ed0..34b634641ab5 100644
--- a/packages/app/src/session/files/session-side-panel.tsx
+++ b/packages/app/src/session/files/session-side-panel.tsx
@@ -542,7 +542,9 @@ export function SessionSidePanel(props: {
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
-
+
+
+
From 55ec49563d377126872a6252132966f70bb7b368 Mon Sep 17 00:00:00 2001
From: Brendonovich
Date: Wed, 16 Sep 2026 04:59:50 +0000
Subject: [PATCH 3/4] fix(app): prevent markdown toggle clipping
---
packages/app/e2e/regression/review-open-file.spec.ts | 10 +++++++++-
packages/app/src/session/files/file-tabs.tsx | 2 +-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts
index 7752d03f556e..0d29e335ffad 100644
--- a/packages/app/e2e/regression/review-open-file.spec.ts
+++ b/packages/app/e2e/regression/review-open-file.spec.ts
@@ -133,7 +133,15 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(panel.getByTitle("README.md")).toBeVisible()
const markdownView = panel.getByRole("group", { name: "Markdown view" })
- await expect(markdownView.getByRole("button", { name: "Rendered" })).toHaveAttribute("aria-pressed", "true")
+ const renderedView = markdownView.getByRole("button", { name: "Rendered" })
+ await expect(renderedView).toHaveAttribute("aria-pressed", "true")
+ await expect
+ .poll(() =>
+ renderedView
+ .locator('[data-slot="segmented-control-v2-item-label"]')
+ .evaluate((label) => label.scrollWidth <= label.clientWidth),
+ )
+ .toBe(true)
await markdownView.getByRole("button", { name: "Source" }).click()
await expect(panel.getByRole("heading", { name: "Rendered README" })).toHaveCount(0)
await expect(panel.getByText("# Rendered README", { exact: true })).toBeVisible()
diff --git a/packages/app/src/session/files/file-tabs.tsx b/packages/app/src/session/files/file-tabs.tsx
index 883b5b3df7a3..38525eda3e88 100644
--- a/packages/app/src/session/files/file-tabs.tsx
+++ b/packages/app/src/session/files/file-tabs.tsx
@@ -479,7 +479,7 @@ export function SessionFileView(props: SessionFileViewProps) {
if (value !== "rendered" && value !== "source") return
setDisplay("markdown", value)
}}
- class="!w-auto"
+ class="!w-[184px]"
aria-label={language.t("session.files.markdown.view")}
>
From c9ec9cd5282f45a7743e25c4872a2032972d9c91 Mon Sep 17 00:00:00 2001
From: Brendonovich
Date: Wed, 16 Sep 2026 08:19:16 +0000
Subject: [PATCH 4/4] feat(app): use markdown view button
---
.../e2e/regression/review-open-file.spec.ts | 21 ++++++--------
packages/app/src/runtime/i18n/en.ts | 5 ++--
packages/app/src/session/files/file-tabs.tsx | 28 ++++++++-----------
3 files changed, 22 insertions(+), 32 deletions(-)
diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts
index 0d29e335ffad..2ee8463101ce 100644
--- a/packages/app/e2e/regression/review-open-file.spec.ts
+++ b/packages/app/e2e/regression/review-open-file.spec.ts
@@ -132,20 +132,15 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible()
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
await expect(panel.getByTitle("README.md")).toBeVisible()
- const markdownView = panel.getByRole("group", { name: "Markdown view" })
- const renderedView = markdownView.getByRole("button", { name: "Rendered" })
- await expect(renderedView).toHaveAttribute("aria-pressed", "true")
- await expect
- .poll(() =>
- renderedView
- .locator('[data-slot="segmented-control-v2-item-label"]')
- .evaluate((label) => label.scrollWidth <= label.clientWidth),
- )
- .toBe(true)
- await markdownView.getByRole("button", { name: "Source" }).click()
+ const viewSource = panel.getByRole("button", { name: "View Source" })
+ await expect(viewSource).toBeVisible()
+ await expect.poll(() => viewSource.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true)
+ await viewSource.click()
await expect(panel.getByRole("heading", { name: "Rendered README" })).toHaveCount(0)
await expect(panel.getByText("# Rendered README", { exact: true })).toBeVisible()
- await markdownView.getByRole("button", { name: "Rendered" }).click()
+ const viewRendered = panel.getByRole("button", { name: "View Rendered" })
+ await expect.poll(() => viewRendered.evaluate((button) => button.scrollWidth <= button.clientWidth)).toBe(true)
+ await viewRendered.click()
await expect(panel.getByRole("heading", { name: "Rendered README" })).toBeVisible()
await expect(sidebar).toHaveCount(0)
@@ -194,7 +189,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
await expect(panel.getByTitle("src/nested.ts")).toBeVisible()
- await expect(panel.getByRole("group", { name: "Markdown view" })).toHaveCount(0)
+ await expect(panel.getByRole("button", { name: /View (Source|Rendered)/ })).toHaveCount(0)
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 5ccb7630154b..91526a596410 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -796,9 +796,8 @@ export const dict = {
"session.review.noChanges": "No changes",
"session.review.noUncommittedChanges": "No uncommitted changes yet",
"session.review.noBranchChanges": "No branch changes yet",
- "session.files.markdown.view": "Markdown view",
- "session.files.markdown.rendered": "Rendered",
- "session.files.markdown.source": "Source",
+ "session.files.markdown.viewSource": "View Source",
+ "session.files.markdown.viewRendered": "View Rendered",
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
diff --git a/packages/app/src/session/files/file-tabs.tsx b/packages/app/src/session/files/file-tabs.tsx
index 38525eda3e88..401a4f934d61 100644
--- a/packages/app/src/session/files/file-tabs.tsx
+++ b/packages/app/src/session/files/file-tabs.tsx
@@ -4,6 +4,7 @@ import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode/session-ui/file"
import { Markdown } from "@opencode/session-ui/markdown"
+import { Button } from "@opencode/ui/button"
import { useFileComponent } from "@opencode/ui/context/file"
import { FileIcon } from "@opencode/ui/file-icon"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
@@ -11,7 +12,6 @@ import { createLineCommentControllerV2 } from "@opencode/session-ui/v2/line-comm
import { sampledChecksum } from "@opencode/util/encode"
import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
-import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
@@ -473,22 +473,18 @@ export function SessionFileView(props: SessionFileViewProps) {
- {
- if (value !== "rendered" && value !== "source") return
- setDisplay("markdown", value)
- }}
- class="!w-[184px]"
- aria-label={language.t("session.files.markdown.view")}
+
+ {language.t(
+ display.markdown === "rendered"
+ ? "session.files.markdown.viewSource"
+ : "session.files.markdown.viewRendered",
+ )}
+