>
)}
+
+ {isFullscreen && (isDesktopFullscreen || hideHeader) && (
+
Date: Mon, 28 Sep 2026 23:22:09 -0400
Subject: [PATCH 2/4] fix(files): open relative markdown links and preinstall
Chromium
Resolve relative and absolute markdown links against the previewed file
and open them in the file browser; external links open in a new tab.
Install Playwright and Chromium into the Manager image with NODE_PATH
and PLAYWRIGHT_BROWSERS_PATH so agents can run browser automation
without a project-local install.
---
Dockerfile | 14 ++++--
backend/src/constants.ts | 14 ++++++
docs/configuration/docker.md | 1 +
docs/features/sandboxing.md | 2 +-
.../components/file-browser/FileBrowser.tsx | 37 ++++++++++-----
.../file-browser/FilePreview.test.tsx | 16 +++++++
.../components/file-browser/FilePreview.tsx | 7 +--
.../file-browser/MarkdownRenderer.tsx | 33 ++++++++++++-
.../file-browser/MobileFilePreviewModal.tsx | 3 ++
.../file-browser/resolve-link-path.test.ts | 47 +++++++++++++++++++
.../file-browser/resolve-link-path.ts | 35 ++++++++++++++
11 files changed, 188 insertions(+), 21 deletions(-)
create mode 100644 frontend/src/components/file-browser/resolve-link-path.test.ts
create mode 100644 frontend/src/components/file-browser/resolve-link-path.ts
diff --git a/Dockerfile b/Dockerfile
index 0042db620..23f1a202e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -111,10 +111,6 @@ RUN echo "Installing microsandbox=${MICROSANDBOX_VERSION} (cachebust=${TOOLS_CAC
chmod -R a+rX /opt/microsandbox && \
msb --version
-RUN echo "Installing Chromium runtime libraries for playwright=${PLAYWRIGHT_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \
- npx --yes "playwright@${PLAYWRIGHT_VERSION}" install-deps chromium && \
- rm -rf /var/lib/apt/lists/* /root/.npm
-
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5003
@@ -125,6 +121,16 @@ ENV XDG_CACHE_HOME=/home/node/.cache
ENV OPENCODE_BUNDLED_VERSION=${OPENCODE_VERSION}
ENV MSB_PATH=/usr/local/bin/msb
ENV MSB_LIBKRUNFW_PATH=/opt/microsandbox/lib/libkrunfw.so
+ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
+ENV NODE_PATH=/usr/local/lib/node_modules
+
+RUN echo "Installing playwright=${PLAYWRIGHT_VERSION} chromium (cachebust=${TOOLS_CACHEBUST})" && \
+ npm install -g "playwright@${PLAYWRIGHT_VERSION}" && \
+ test "$(playwright --version | awk '{print $2}')" = "${PLAYWRIGHT_VERSION}" && \
+ playwright install --with-deps chromium && \
+ chown -R node:node "${PLAYWRIGHT_BROWSERS_PATH}" && \
+ chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" && \
+ rm -rf /var/lib/apt/lists/* /root/.npm
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder /app/shared ./shared
diff --git a/backend/src/constants.ts b/backend/src/constants.ts
index ca4d348d3..0712696ed 100644
--- a/backend/src/constants.ts
+++ b/backend/src/constants.ts
@@ -49,6 +49,20 @@ Prefer **pnpm** or **bun** over npm for installing dependencies to save disk spa
- uv is pre-installed in the container and provides faster package installation
- .venv directories created in repos will persist but can be removed safely
+## Browser Automation
+
+Playwright and Chromium are pre-installed, so browser automation runs without a project-local install:
+
+- The \`playwright\` package resolves from any working directory via \`NODE_PATH\`
+- Chromium is installed at \`PLAYWRIGHT_BROWSERS_PATH\`
+- Run scripts with \`node\`, \`bun\`, or \`pnpm\`; no extra setup is needed
+
+\`\`\`bash
+node -e "const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })();"
+\`\`\`
+
+If Chromium fails to launch because the host kernel restricts user namespaces, pass \`--no-sandbox\`; the container is already the isolation boundary.
+
## General Guidelines
- This file is merged with any AGENTS.md files in individual repositories
diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md
index 95a97c93d..12654f93e 100644
--- a/docs/configuration/docker.md
+++ b/docs/configuration/docker.md
@@ -491,6 +491,7 @@ Instructions for AI agents working in the container:
- Reserved ports information
- Available dev server ports
- Docker-specific guidelines
+- Pre-installed browser automation (Playwright and Chromium)
### Editing
diff --git a/docs/features/sandboxing.md b/docs/features/sandboxing.md
index 435b7d411..e58a0d1a6 100644
--- a/docs/features/sandboxing.md
+++ b/docs/features/sandboxing.md
@@ -246,7 +246,7 @@ Pin a concrete tag or digest rather than a floating one. Attestation compares th
Override any tool pin at build time with its `ARG`, for example `--build-arg PLAYWRIGHT_VERSION=1.62.0` or `--build-arg RUST_VERSION=1.97.0`; the build asserts the installed version, so a typo fails early instead of shipping a stale tool. If your project drives Playwright itself, match this version to the one in your `package.json`; a mismatched browser revision makes Playwright refuse to launch. Rebuild and republish the guest image, then update the `SANDBOX.IMAGE` digest, whenever you change a pin.
-The Manager image itself carries the same Chromium runtime libraries, resolved by `playwright install-deps chromium` for the same `PLAYWRIGHT_VERSION` at build time. That is what makes a Playwright e2e suite run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime. Both images track one pin, so bumping `PLAYWRIGHT_VERSION` refreshes the sandbox browser and the Manager's system libraries together.
+The Manager image itself carries the same Chromium browser and `playwright` package, installed by `playwright install --with-deps chromium` for the same `PLAYWRIGHT_VERSION` at build time and made world-readable. That is what makes browser automation run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime; `NODE_PATH=/usr/local/lib/node_modules` lets agent code resolve `playwright` from any working directory. Both images track one pin, so bumping `PLAYWRIGHT_VERSION` refreshes the sandbox browser and the Manager's browser together.
## Caveats
diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx
index a27ec8a1d..c851bf620 100644
--- a/frontend/src/components/file-browser/FileBrowser.tsx
+++ b/frontend/src/components/file-browser/FileBrowser.tsx
@@ -236,21 +236,23 @@ useEffect(() => {
getCurrentPath: () => currentPath,
}), [currentPath, goToParentDirectory, canNavigateUp])
- const handleFileSelect = useCallback(async (file: FileInfo) => {
- if (file.isDirectory) {
- setSelectedFile(null)
- return
- }
-
- // Fetch the full file content when selecting a file
+ const openFilePath = useCallback(async (path: string) => {
setLoading(true)
try {
- const response = await fetch(getFileApiUrl(file.path))
+ const response = await fetch(getFileApiUrl(path))
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`)
}
- const fullFileData = await response.json()
+ const fullFileData: FileInfo = await response.json()
+ if (fullFileData.isDirectory) {
+ setSelectedFile(null)
+ setIsPreviewModalOpen(false)
+ onPreviewStateChange?.(false)
+ await loadFiles(fullFileData.path)
+ return
+ }
+
setSelectedFile(fullFileData)
onFileSelect?.(fullFileData)
@@ -265,7 +267,16 @@ useEffect(() => {
} finally {
setLoading(false)
}
- }, [onFileSelect, isMobile, onPreviewStateChange])
+ }, [onFileSelect, isMobile, onPreviewStateChange, loadFiles])
+
+ const handleFileSelect = useCallback(async (file: FileInfo) => {
+ if (file.isDirectory) {
+ setSelectedFile(null)
+ return
+ }
+
+ await openFilePath(file.path)
+ }, [openFilePath])
const handleCloseModal = useCallback(() => {
setIsPreviewModalOpen(false)
@@ -639,7 +650,7 @@ useEffect(() => {
{!isMobile && (
{selectedFile && !selectedFile.isDirectory ? (
-
+
) : (
Select a file to preview
@@ -655,6 +666,7 @@ useEffect(() => {
onClose={handleCloseModal}
file={selectedFile}
showFilePreviewHeader={true}
+ onOpenFile={openFilePath}
/>
)
@@ -740,7 +752,7 @@ useEffect(() => {
{!isMobile && (
{selectedFile && !selectedFile.isDirectory ? (
-
+
) : (
Select a file to preview
@@ -756,6 +768,7 @@ useEffect(() => {
isOpen={isMobile && isPreviewModalOpen}
onClose={handleCloseModal}
file={selectedFile}
+ onOpenFile={openFilePath}
/>
{uploadDialog}
diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx
index 33cb56161..22feb0842 100644
--- a/frontend/src/components/file-browser/FilePreview.test.tsx
+++ b/frontend/src/components/file-browser/FilePreview.test.tsx
@@ -89,6 +89,22 @@ describe('FilePreview header buttons', () => {
expect(onCloseModal).toHaveBeenCalled()
})
+ it('opens relative markdown links in the file browser and external links in a new tab', () => {
+ const onOpenFile = vi.fn()
+ const file = {
+ ...textFile('index.md', 'text/markdown'),
+ content: btoa('- [ ] [report](sub/report.html)\n\n[site](https://example.com)'),
+ }
+ render(
)
+
+ fireEvent.click(screen.getByRole('link', { name: 'report' }))
+ expect(onOpenFile).toHaveBeenCalledWith('docker/sub/report.html')
+
+ const external = screen.getByRole('link', { name: 'site' })
+ expect(external).toHaveAttribute('target', '_blank')
+ expect(external).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
it('keeps the success and destructive tints on the edit actions in dark mode', () => {
render(
)
diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx
index b2404b17f..9fd935eaf 100644
--- a/frontend/src/components/file-browser/FilePreview.tsx
+++ b/frontend/src/components/file-browser/FilePreview.tsx
@@ -20,9 +20,10 @@ interface FilePreviewProps {
onCloseModal?: () => void
onFileSaved?: () => void
initialLineNumber?: number
+ onOpenFile?: (path: string) => void
}
-export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) {
+export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber, onOpenFile }: FilePreviewProps) {
const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown'
const isHtmlFile = /\.html?$/i.test(file.name) || file.mimeType === 'text/html'
const hasRenderedPreview = isMarkdownFile || isHtmlFile
@@ -276,7 +277,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
) : fullContent ? (
-
+
) : null}
>
)}
@@ -315,7 +316,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
}
if (isMarkdownFile && renderedPreview) {
- return
+ return
}
const lines = displayContent.split('\n')
diff --git a/frontend/src/components/file-browser/MarkdownRenderer.tsx b/frontend/src/components/file-browser/MarkdownRenderer.tsx
index c1eebd332..b5c32d645 100644
--- a/frontend/src/components/file-browser/MarkdownRenderer.tsx
+++ b/frontend/src/components/file-browser/MarkdownRenderer.tsx
@@ -4,14 +4,19 @@ import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import { markdownComponents } from './MarkdownComponents'
+import { resolveLinkedFilePath } from './resolve-link-path'
import type { Components } from 'react-markdown'
interface MarkdownRendererProps {
content: string
className?: string
onContentChange?: (newContent: string) => void
+ filePath?: string
+ onOpenFile?: (path: string) => void
}
+const EXTERNAL_LINK = /^https?:\/\//i
+
interface TaskItem {
lineIndex: number
checked: boolean
@@ -29,7 +34,7 @@ function parseTaskItems(content: string): TaskItem[] {
return items
}
-export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '', onContentChange }: MarkdownRendererProps) {
+export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '', onContentChange, filePath, onOpenFile }: MarkdownRendererProps) {
const taskItems = useMemo(() => parseTaskItems(content), [content])
const handleToggle = useCallback((taskItem: TaskItem) => {
@@ -74,6 +79,32 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
return
},
+ a(props) {
+ const { href, children, ...rest } = props
+ delete (rest as Record
).node
+
+ const linkedPath = href && filePath && onOpenFile ? resolveLinkedFilePath(filePath, href) : null
+ if (linkedPath && onOpenFile) {
+ return (
+ {
+ event.preventDefault()
+ onOpenFile(linkedPath)
+ }}
+ >
+ {children}
+
+ )
+ }
+
+ if (href && EXTERNAL_LINK.test(href)) {
+ return {children}
+ }
+
+ return {children}
+ },
}
return (
diff --git a/frontend/src/components/file-browser/MobileFilePreviewModal.tsx b/frontend/src/components/file-browser/MobileFilePreviewModal.tsx
index 9e70cd7f9..2d25bbeb7 100644
--- a/frontend/src/components/file-browser/MobileFilePreviewModal.tsx
+++ b/frontend/src/components/file-browser/MobileFilePreviewModal.tsx
@@ -10,6 +10,7 @@ interface MobileFilePreviewModalProps {
onClose: () => void;
file: FileInfo | null;
showFilePreviewHeader?: boolean;
+ onOpenFile?: (path: string) => void;
}
export const MobileFilePreviewModal = memo(function MobileFilePreviewModal({
@@ -17,6 +18,7 @@ export const MobileFilePreviewModal = memo(function MobileFilePreviewModal({
onClose,
file,
showFilePreviewHeader = false,
+ onOpenFile,
}: MobileFilePreviewModalProps) {
const [localFile, setLocalFile] = useState(null);
const isClosingRef = useRef(false);
@@ -77,6 +79,7 @@ export const MobileFilePreviewModal = memo(function MobileFilePreviewModal({
hideHeader={!showFilePreviewHeader}
isMobileModal={showFilePreviewHeader}
onCloseModal={handleClose}
+ onOpenFile={onOpenFile}
/>
diff --git a/frontend/src/components/file-browser/resolve-link-path.test.ts b/frontend/src/components/file-browser/resolve-link-path.test.ts
new file mode 100644
index 000000000..0f6ab6253
--- /dev/null
+++ b/frontend/src/components/file-browser/resolve-link-path.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest'
+import { resolveLinkedFilePath } from './resolve-link-path'
+
+const FROM = 'opencode-manager/ocm-link-test/index.md'
+
+describe('resolveLinkedFilePath', () => {
+ it('resolves sibling, dot-relative, nested, and parent links against the containing file', () => {
+ expect(resolveLinkedFilePath(FROM, 'report.html')).toBe('opencode-manager/ocm-link-test/report.html')
+ expect(resolveLinkedFilePath(FROM, './report.html')).toBe('opencode-manager/ocm-link-test/report.html')
+ expect(resolveLinkedFilePath(FROM, 'sub/page.html')).toBe('opencode-manager/ocm-link-test/sub/page.html')
+ expect(resolveLinkedFilePath(FROM, '../README.md')).toBe('opencode-manager/README.md')
+ })
+
+ it('resolves folder links without the trailing slash', () => {
+ expect(resolveLinkedFilePath(FROM, 'sub/')).toBe('opencode-manager/ocm-link-test/sub')
+ expect(resolveLinkedFilePath(FROM, '..')).toBe('opencode-manager')
+ })
+
+ it('keeps parent segments above the repos root for the files API to validate', () => {
+ expect(resolveLinkedFilePath('opencode-manager/index.md', '../../notes.md')).toBe('../notes.md')
+ expect(resolveLinkedFilePath('../.config/opencode/AGENTS.md', 'skills/x.md')).toBe('../.config/opencode/skills/x.md')
+ })
+
+ it('returns absolute paths unchanged apart from query, fragment, and trailing slash', () => {
+ expect(resolveLinkedFilePath(FROM, '/workspace/repos/app/report.html#top')).toBe('/workspace/repos/app/report.html')
+ expect(resolveLinkedFilePath(FROM, '/workspace/repos/app/')).toBe('/workspace/repos/app')
+ })
+
+ it('drops query and fragment and decodes encoded names', () => {
+ expect(resolveLinkedFilePath(FROM, 'report.html#summary')).toBe('opencode-manager/ocm-link-test/report.html')
+ expect(resolveLinkedFilePath(FROM, 'my%20report.html?x=1')).toBe('opencode-manager/ocm-link-test/my report.html')
+ expect(resolveLinkedFilePath('repo/my docs/index.md', 'a.html')).toBe('repo/my docs/a.html')
+ })
+
+ it('ignores anchors, protocol-relative URLs, and scheme links', () => {
+ expect(resolveLinkedFilePath(FROM, '#section')).toBeNull()
+ expect(resolveLinkedFilePath(FROM, '//example.com/a')).toBeNull()
+ expect(resolveLinkedFilePath(FROM, 'https://example.com')).toBeNull()
+ expect(resolveLinkedFilePath(FROM, 'mailto:a@b.c')).toBeNull()
+ expect(resolveLinkedFilePath(FROM, '')).toBeNull()
+ expect(resolveLinkedFilePath(FROM, '/')).toBeNull()
+ })
+
+ it('returns null for malformed percent-encoding', () => {
+ expect(resolveLinkedFilePath(FROM, 'bad%zz.html')).toBeNull()
+ })
+})
diff --git a/frontend/src/components/file-browser/resolve-link-path.ts b/frontend/src/components/file-browser/resolve-link-path.ts
new file mode 100644
index 000000000..f2830f769
--- /dev/null
+++ b/frontend/src/components/file-browser/resolve-link-path.ts
@@ -0,0 +1,35 @@
+const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
+
+/**
+ * Resolves a markdown link to the workspace path of the file or folder it points at.
+ * Relative links resolve against the containing file; absolute paths are returned as-is for the files API to validate.
+ * Returns null for anchors, protocol-relative URLs, and links with a URL scheme.
+ */
+export function resolveLinkedFilePath(fromFilePath: string, href: string): string | null {
+ if (!href || href.startsWith('#') || href.startsWith('//') || URL_SCHEME.test(href)) {
+ return null
+ }
+
+ let linkPath: string
+ try {
+ linkPath = decodeURIComponent(href.split(/[?#]/)[0])
+ } catch {
+ return null
+ }
+
+ if (linkPath.startsWith('/')) {
+ return linkPath.replace(/\/+$/, '') || null
+ }
+
+ const segments = fromFilePath.split('/').filter(Boolean).slice(0, -1)
+ for (const segment of linkPath.split('/')) {
+ if (segment === '' || segment === '.') continue
+ if (segment === '..' && segments.length > 0 && segments[segments.length - 1] !== '..') {
+ segments.pop()
+ } else {
+ segments.push(segment)
+ }
+ }
+
+ return segments.join('/') || null
+}
From a672f35786762eb45c86d00c89d9e0848b92e77f Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Mon, 28 Sep 2026 23:36:48 -0400
Subject: [PATCH 3/4] refactor(files): share markdown link handling across
renderers
---
.../file-browser/MarkdownRenderer.tsx | 38 ++++---------
.../file-browser/resolve-link-path.test.ts | 47 ----------------
.../file-browser/resolve-link-path.ts | 35 ------------
.../components/message/MessagePart.test.tsx | 17 ++++++
.../src/components/message/MessagePart.tsx | 2 +-
frontend/src/components/message/TextPart.tsx | 9 +++-
frontend/src/components/ui/markdown-link.tsx | 37 +++++++++++++
frontend/src/lib/markdownLinks.test.ts | 54 +++++++++++++++++++
frontend/src/lib/markdownLinks.ts | 45 ++++++++++++++++
9 files changed, 172 insertions(+), 112 deletions(-)
delete mode 100644 frontend/src/components/file-browser/resolve-link-path.test.ts
delete mode 100644 frontend/src/components/file-browser/resolve-link-path.ts
create mode 100644 frontend/src/components/ui/markdown-link.tsx
create mode 100644 frontend/src/lib/markdownLinks.test.ts
create mode 100644 frontend/src/lib/markdownLinks.ts
diff --git a/frontend/src/components/file-browser/MarkdownRenderer.tsx b/frontend/src/components/file-browser/MarkdownRenderer.tsx
index b5c32d645..35e589c7a 100644
--- a/frontend/src/components/file-browser/MarkdownRenderer.tsx
+++ b/frontend/src/components/file-browser/MarkdownRenderer.tsx
@@ -4,7 +4,8 @@ import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import { markdownComponents } from './MarkdownComponents'
-import { resolveLinkedFilePath } from './resolve-link-path'
+import { MarkdownLink } from '@/components/ui/markdown-link'
+import { resolvePathFromFile } from '@/lib/markdownLinks'
import type { Components } from 'react-markdown'
interface MarkdownRendererProps {
@@ -15,8 +16,6 @@ interface MarkdownRendererProps {
onOpenFile?: (path: string) => void
}
-const EXTERNAL_LINK = /^https?:\/\//i
-
interface TaskItem {
lineIndex: number
checked: boolean
@@ -59,6 +58,14 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
}
}, [handleToggle, taskItems])
+ const handleOpenLocalPath = useMemo(() => {
+ if (!filePath || !onOpenFile) return undefined
+ return (linkPath: string) => {
+ const resolvedPath = resolvePathFromFile(filePath, linkPath)
+ if (resolvedPath) onOpenFile(resolvedPath)
+ }
+ }, [filePath, onOpenFile])
+
const components: Components = {
...markdownComponents,
input(props) {
@@ -80,30 +87,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
return
},
a(props) {
- const { href, children, ...rest } = props
- delete (rest as Record
).node
-
- const linkedPath = href && filePath && onOpenFile ? resolveLinkedFilePath(filePath, href) : null
- if (linkedPath && onOpenFile) {
- return (
- {
- event.preventDefault()
- onOpenFile(linkedPath)
- }}
- >
- {children}
-
- )
- }
-
- if (href && EXTERNAL_LINK.test(href)) {
- return {children}
- }
-
- return {children}
+ return
},
}
diff --git a/frontend/src/components/file-browser/resolve-link-path.test.ts b/frontend/src/components/file-browser/resolve-link-path.test.ts
deleted file mode 100644
index 0f6ab6253..000000000
--- a/frontend/src/components/file-browser/resolve-link-path.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { resolveLinkedFilePath } from './resolve-link-path'
-
-const FROM = 'opencode-manager/ocm-link-test/index.md'
-
-describe('resolveLinkedFilePath', () => {
- it('resolves sibling, dot-relative, nested, and parent links against the containing file', () => {
- expect(resolveLinkedFilePath(FROM, 'report.html')).toBe('opencode-manager/ocm-link-test/report.html')
- expect(resolveLinkedFilePath(FROM, './report.html')).toBe('opencode-manager/ocm-link-test/report.html')
- expect(resolveLinkedFilePath(FROM, 'sub/page.html')).toBe('opencode-manager/ocm-link-test/sub/page.html')
- expect(resolveLinkedFilePath(FROM, '../README.md')).toBe('opencode-manager/README.md')
- })
-
- it('resolves folder links without the trailing slash', () => {
- expect(resolveLinkedFilePath(FROM, 'sub/')).toBe('opencode-manager/ocm-link-test/sub')
- expect(resolveLinkedFilePath(FROM, '..')).toBe('opencode-manager')
- })
-
- it('keeps parent segments above the repos root for the files API to validate', () => {
- expect(resolveLinkedFilePath('opencode-manager/index.md', '../../notes.md')).toBe('../notes.md')
- expect(resolveLinkedFilePath('../.config/opencode/AGENTS.md', 'skills/x.md')).toBe('../.config/opencode/skills/x.md')
- })
-
- it('returns absolute paths unchanged apart from query, fragment, and trailing slash', () => {
- expect(resolveLinkedFilePath(FROM, '/workspace/repos/app/report.html#top')).toBe('/workspace/repos/app/report.html')
- expect(resolveLinkedFilePath(FROM, '/workspace/repos/app/')).toBe('/workspace/repos/app')
- })
-
- it('drops query and fragment and decodes encoded names', () => {
- expect(resolveLinkedFilePath(FROM, 'report.html#summary')).toBe('opencode-manager/ocm-link-test/report.html')
- expect(resolveLinkedFilePath(FROM, 'my%20report.html?x=1')).toBe('opencode-manager/ocm-link-test/my report.html')
- expect(resolveLinkedFilePath('repo/my docs/index.md', 'a.html')).toBe('repo/my docs/a.html')
- })
-
- it('ignores anchors, protocol-relative URLs, and scheme links', () => {
- expect(resolveLinkedFilePath(FROM, '#section')).toBeNull()
- expect(resolveLinkedFilePath(FROM, '//example.com/a')).toBeNull()
- expect(resolveLinkedFilePath(FROM, 'https://example.com')).toBeNull()
- expect(resolveLinkedFilePath(FROM, 'mailto:a@b.c')).toBeNull()
- expect(resolveLinkedFilePath(FROM, '')).toBeNull()
- expect(resolveLinkedFilePath(FROM, '/')).toBeNull()
- })
-
- it('returns null for malformed percent-encoding', () => {
- expect(resolveLinkedFilePath(FROM, 'bad%zz.html')).toBeNull()
- })
-})
diff --git a/frontend/src/components/file-browser/resolve-link-path.ts b/frontend/src/components/file-browser/resolve-link-path.ts
deleted file mode 100644
index f2830f769..000000000
--- a/frontend/src/components/file-browser/resolve-link-path.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
-
-/**
- * Resolves a markdown link to the workspace path of the file or folder it points at.
- * Relative links resolve against the containing file; absolute paths are returned as-is for the files API to validate.
- * Returns null for anchors, protocol-relative URLs, and links with a URL scheme.
- */
-export function resolveLinkedFilePath(fromFilePath: string, href: string): string | null {
- if (!href || href.startsWith('#') || href.startsWith('//') || URL_SCHEME.test(href)) {
- return null
- }
-
- let linkPath: string
- try {
- linkPath = decodeURIComponent(href.split(/[?#]/)[0])
- } catch {
- return null
- }
-
- if (linkPath.startsWith('/')) {
- return linkPath.replace(/\/+$/, '') || null
- }
-
- const segments = fromFilePath.split('/').filter(Boolean).slice(0, -1)
- for (const segment of linkPath.split('/')) {
- if (segment === '' || segment === '.') continue
- if (segment === '..' && segments.length > 0 && segments[segments.length - 1] !== '..') {
- segments.pop()
- } else {
- segments.push(segment)
- }
- }
-
- return segments.join('/') || null
-}
diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx
index a7f973f3f..ddc651e05 100644
--- a/frontend/src/components/message/MessagePart.test.tsx
+++ b/frontend/src/components/message/MessagePart.test.tsx
@@ -91,6 +91,23 @@ describe('MessagePart', () => {
expect(screen.getByText('Hello, this is a text message')).toBeInTheDocument()
})
+ it('opens local markdown links in a text part through onFileClick and external links in a new tab', () => {
+ const onFileClick = vi.fn()
+ renderWithProviders(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('link', { name: 'recap' }))
+ expect(onFileClick).toHaveBeenCalledWith('recaps/daily-overview.html')
+
+ const external = screen.getByRole('link', { name: 'site' })
+ expect(external).toHaveAttribute('target', '_blank')
+ expect(external).toHaveAttribute('rel', 'noopener noreferrer')
+ })
+
it('renders null for an empty text part', () => {
const { container } = renderWithProviders( )
diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx
index 8f8ac4553..4d2d08b8e 100644
--- a/frontend/src/components/message/MessagePart.tsx
+++ b/frontend/src/components/message/MessagePart.tsx
@@ -23,7 +23,7 @@ export const MessagePart = memo(function MessagePart({ part, messageID, director
switch (part.type) {
case 'text':
- return
+ return
case 'reasoning':
if (simpleChatMode || !showReasoning) return null
if (!part.text.trim()) return null
diff --git a/frontend/src/components/message/TextPart.tsx b/frontend/src/components/message/TextPart.tsx
index 596f7f565..508485ccd 100644
--- a/frontend/src/components/message/TextPart.tsx
+++ b/frontend/src/components/message/TextPart.tsx
@@ -6,11 +6,13 @@ import rehypeRaw from 'rehype-raw'
import mermaid from 'mermaid'
import { Maximize2, X, AlertCircle } from 'lucide-react'
import { CopyButton } from '@/components/ui/copy-button'
+import { MarkdownLink } from '@/components/ui/markdown-link'
import { useTheme } from '@/hooks/useTheme'
import 'highlight.js/styles/github-dark.css'
interface TextPartProps {
text: string
+ onFileClick?: (filePath: string) => void
}
interface MermaidBlockProps {
@@ -181,7 +183,7 @@ function isMermaidBlockComplete(text: string): boolean {
return false
}
-export function TextPart({ text }: TextPartProps) {
+export function TextPart({ text, onFileClick }: TextPartProps) {
const mermaidComplete = React.useMemo(() => {
return text ? isMermaidBlockComplete(text) : false
}, [text])
@@ -257,7 +259,10 @@ export function TextPart({ text }: TextPartProps) {
)
- }
+ },
+ a(props) {
+ return
+ },
}}
>
{text}
diff --git a/frontend/src/components/ui/markdown-link.tsx b/frontend/src/components/ui/markdown-link.tsx
new file mode 100644
index 000000000..7a5b4bf62
--- /dev/null
+++ b/frontend/src/components/ui/markdown-link.tsx
@@ -0,0 +1,37 @@
+import type { ComponentPropsWithoutRef } from 'react'
+import type { ExtraProps } from 'react-markdown'
+import { getLocalLinkPath, isExternalLink } from '@/lib/markdownLinks'
+
+type MarkdownLinkProps = ComponentPropsWithoutRef<'a'> & ExtraProps & {
+ onOpenLocalPath?: (linkPath: string) => void
+}
+
+/**
+ * Renders a markdown link. Local file links call `onOpenLocalPath` instead of navigating the app,
+ * and external http(s) links open in a new tab.
+ */
+export function MarkdownLink({ href, children, onOpenLocalPath, ...rest }: MarkdownLinkProps) {
+ delete (rest as Record
).node
+ const localPath = onOpenLocalPath ? getLocalLinkPath(href) : null
+
+ if (localPath && onOpenLocalPath) {
+ return (
+ {
+ event.preventDefault()
+ onOpenLocalPath(localPath)
+ }}
+ >
+ {children}
+
+ )
+ }
+
+ if (isExternalLink(href)) {
+ return {children}
+ }
+
+ return {children}
+}
diff --git a/frontend/src/lib/markdownLinks.test.ts b/frontend/src/lib/markdownLinks.test.ts
new file mode 100644
index 000000000..9b0b99b23
--- /dev/null
+++ b/frontend/src/lib/markdownLinks.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from 'vitest'
+import { getLocalLinkPath, resolvePathFromFile, isExternalLink } from './markdownLinks'
+
+const FROM = 'opencode-manager/ocm-link-test/index.md'
+
+describe('getLocalLinkPath', () => {
+ it('returns relative and absolute paths without query, fragment, or trailing slash', () => {
+ expect(getLocalLinkPath('recaps/daily-overview-2026-09-29.html')).toBe('recaps/daily-overview-2026-09-29.html')
+ expect(getLocalLinkPath('./report.html#top')).toBe('./report.html')
+ expect(getLocalLinkPath('/workspace/repos/app/report.html?x=1')).toBe('/workspace/repos/app/report.html')
+ expect(getLocalLinkPath('sub/')).toBe('sub')
+ expect(getLocalLinkPath('/')).toBe('/')
+ expect(getLocalLinkPath('my%20report.html')).toBe('my report.html')
+ })
+
+ it('ignores anchors, protocol-relative URLs, scheme links, empty, and malformed encodings', () => {
+ expect(getLocalLinkPath(undefined)).toBeNull()
+ expect(getLocalLinkPath('')).toBeNull()
+ expect(getLocalLinkPath('#section')).toBeNull()
+ expect(getLocalLinkPath('//example.com/a')).toBeNull()
+ expect(getLocalLinkPath('https://example.com')).toBeNull()
+ expect(getLocalLinkPath('mailto:a@b.c')).toBeNull()
+ expect(getLocalLinkPath('bad%zz.html')).toBeNull()
+ })
+})
+
+describe('resolvePathFromFile', () => {
+ it('resolves sibling, dot-relative, nested, parent, and folder links against the containing file', () => {
+ expect(resolvePathFromFile(FROM, 'report.html')).toBe('opencode-manager/ocm-link-test/report.html')
+ expect(resolvePathFromFile(FROM, './report.html')).toBe('opencode-manager/ocm-link-test/report.html')
+ expect(resolvePathFromFile(FROM, 'sub/page.html')).toBe('opencode-manager/ocm-link-test/sub/page.html')
+ expect(resolvePathFromFile(FROM, '../README.md')).toBe('opencode-manager/README.md')
+ expect(resolvePathFromFile(FROM, '..')).toBe('opencode-manager')
+ expect(resolvePathFromFile('repo/my docs/index.md', 'a.html')).toBe('repo/my docs/a.html')
+ })
+
+ it('keeps parent segments above the repos root for the files API to validate', () => {
+ expect(resolvePathFromFile('opencode-manager/index.md', '../../notes.md')).toBe('../notes.md')
+ expect(resolvePathFromFile('../.config/opencode/AGENTS.md', 'skills/x.md')).toBe('../.config/opencode/skills/x.md')
+ })
+
+ it('returns absolute paths unchanged', () => {
+ expect(resolvePathFromFile(FROM, '/workspace/repos/app/report.html')).toBe('/workspace/repos/app/report.html')
+ })
+})
+
+describe('isExternalLink', () => {
+ it('detects http and https links only', () => {
+ expect(isExternalLink('https://example.com')).toBe(true)
+ expect(isExternalLink('http://example.com')).toBe(true)
+ expect(isExternalLink('recaps/a.html')).toBe(false)
+ expect(isExternalLink(undefined)).toBe(false)
+ })
+})
diff --git a/frontend/src/lib/markdownLinks.ts b/frontend/src/lib/markdownLinks.ts
new file mode 100644
index 000000000..82147fd9f
--- /dev/null
+++ b/frontend/src/lib/markdownLinks.ts
@@ -0,0 +1,45 @@
+const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
+
+/**
+ * Extracts the local file path a markdown link points at, decoded and without query or fragment.
+ * Returns null for anchors, protocol-relative URLs, links with a URL scheme, and malformed encodings.
+ */
+export function getLocalLinkPath(href: string | undefined): string | null {
+ if (!href || href.startsWith('#') || href.startsWith('//') || URL_SCHEME.test(href)) {
+ return null
+ }
+
+ try {
+ const linkPath = decodeURIComponent(href.split(/[?#]/)[0]).replace(/(.)\/+$/, '$1')
+ return linkPath || null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Resolves a local link path against the workspace path of the file that contains it.
+ * Absolute paths are returned unchanged for the files API to validate; leading parent segments are kept.
+ */
+export function resolvePathFromFile(fromFilePath: string, linkPath: string): string | null {
+ if (linkPath.startsWith('/')) {
+ return linkPath
+ }
+
+ const segments = fromFilePath.split('/').filter(Boolean).slice(0, -1)
+ for (const segment of linkPath.split('/')) {
+ if (segment === '' || segment === '.') continue
+ if (segment === '..' && segments.length > 0 && segments[segments.length - 1] !== '..') {
+ segments.pop()
+ } else {
+ segments.push(segment)
+ }
+ }
+
+ return segments.join('/') || null
+}
+
+/** Returns whether a link targets an external http(s) page. */
+export function isExternalLink(href: string | undefined): boolean {
+ return !!href && /^https?:\/\//i.test(href)
+}
From d3284233b50874003b1fed21a39df0f2bf2f86ed Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Tue, 29 Sep 2026 00:04:12 -0400
Subject: [PATCH 4/4] test(docker): cover the preinstalled playwright chromium
step
---
backend/test/scripts/docker-config.test.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/backend/test/scripts/docker-config.test.ts b/backend/test/scripts/docker-config.test.ts
index fab11a94a..45b511c1f 100644
--- a/backend/test/scripts/docker-config.test.ts
+++ b/backend/test/scripts/docker-config.test.ts
@@ -161,16 +161,23 @@ describe('chromium runtime libraries for playwright', () => {
const sandboxDockerfile = read(join(repoRoot, 'Dockerfile.sandbox'))
const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml'))
const installRun = dockerfile.slice(
- dockerfile.indexOf('Installing Chromium runtime libraries'),
- dockerfile.indexOf('ENV NODE_ENV=production'),
+ dockerfile.indexOf('Installing playwright='),
+ dockerfile.indexOf('COPY --from=deps --chown=node:node /app/node_modules'),
)
it('declares PLAYWRIGHT_VERSION next to the other tool args', () => {
expect(dockerfile).toMatch(/ARG PLAYWRIGHT_VERSION=1\.63\.0/)
})
- it('resolves the system dependency list from the pinned playwright version', () => {
- expect(installRun).toMatch(/npx --yes "playwright@\$\{PLAYWRIGHT_VERSION\}" install-deps chromium/)
+ it('installs the pinned playwright and verifies its version', () => {
+ expect(installRun).toContain('npm install -g "playwright@${PLAYWRIGHT_VERSION}"')
+ expect(installRun).toContain('test "$(playwright --version | awk \'{print $2}\')" = "${PLAYWRIGHT_VERSION}"')
+ })
+
+ it('preinstalls chromium with its system dependencies into a shared browsers path', () => {
+ expect(dockerfile).toContain('ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright')
+ expect(installRun).toContain('playwright install --with-deps chromium')
+ expect(installRun).toContain('chown -R node:node "${PLAYWRIGHT_BROWSERS_PATH}"')
})
it('does not hand-maintain a package list', () => {