From 16f00c50d4756c023eeb229e5fe9d84486345d0f Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:51:31 -0400 Subject: [PATCH 1/4] feat(files): add fullscreen preview with a minimal close control Add a fullscreen toggle to the desktop file preview header. Fullscreen fills the viewport, hides the header, and shows only a small on-top X to exit. Keep the mobile preview fullscreen by default with its close button layered above the content, and give the headerless mobile preview the same small on-top X. --- .../file-browser/FilePreview.test.tsx | 36 ++++++++++++++++++- .../components/file-browser/FilePreview.tsx | 31 +++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx index bf3ae3dd6..33cb56161 100644 --- a/frontend/src/components/file-browser/FilePreview.test.tsx +++ b/frontend/src/components/file-browser/FilePreview.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { FilePreview } from './FilePreview' import type { FileInfo } from '@/types/files' @@ -55,6 +55,40 @@ describe('FilePreview header buttons', () => { expect(screen.getByText('# heading')).toBeInTheDocument() }) + it('enters fullscreen from the header toggle and exits with the small close button', () => { + render() + + expect(screen.queryByTitle('Exit fullscreen')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTitle('Enter fullscreen')) + + expect(screen.queryByTitle('Enter fullscreen')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTitle('Exit fullscreen')) + + expect(screen.getByTitle('Enter fullscreen')).toBeInTheDocument() + }) + + it('closes a mobile preview from the on-top close button without a fullscreen toggle', () => { + const onCloseModal = vi.fn() + render() + + expect(screen.queryByTitle('Enter fullscreen')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTitle('Close preview')) + + expect(onCloseModal).toHaveBeenCalled() + }) + + it('shows an on-top close button for a headerless mobile preview', () => { + const onCloseModal = vi.fn() + render() + + fireEvent.click(screen.getByTitle('Close preview')) + + expect(onCloseModal).toHaveBeenCalled() + }) + 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 fb91e1a50..b2404b17f 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Button } from '@/components/ui/button' -import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code, ExternalLink } from 'lucide-react' +import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code, ExternalLink, Maximize2 } from 'lucide-react' import type { FileInfo } from '@/types/files' import { getFileApiUrl } from '@/api/files' import { saveFileFromUrl } from '@/lib/download' @@ -41,6 +41,9 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const [localMdContent, setLocalMdContent] = useState(null) const virtualizedRef = useRef(null) const contentRef = useRef(null) + const isMobilePreview = isMobileModal || Boolean(onCloseModal) + const [isFullscreen, setIsFullscreen] = useState(isMobilePreview) + const isDesktopFullscreen = isFullscreen && !isMobilePreview const shouldVirtualize = file.size > VIRTUALIZATION_THRESHOLD_BYTES && !file.mimeType?.startsWith('image/') const isMarkdownTooLarge = file.size > MARKDOWN_PREVIEW_SIZE_LIMIT @@ -373,10 +376,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const showCancelButton = viewMode === 'edit' return ( -
- {!hideHeader && ( +
+ {!hideHeader && !isDesktopFullscreen && ( <> -
+

{file.name} @@ -451,8 +454,14 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, )} + {!isMobilePreview && !isFullscreen && viewMode !== 'edit' && ( + + )} + {viewMode !== 'edit' && isMobileModal && onCloseModal && ( - )} @@ -460,6 +469,18 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,

)} + + {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) { {children}
) - } + }, + 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', () => {