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 }) { - state.setMenu("open", false)} /> + state.setMenu("open", false)} /> 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")} +