feat: make chat file references clickable and responsive (#587)

* docs: clarify named and quick Cloudflare tunnel usage

* feat: make chat file paths openable from rendered responses

* perf: speed up chat file-path links and open behavior

* fix: open chat file references at mentioned lines

* fix: prevent context panel flicker on blocked file opens
This commit is contained in:
Iuliia Ivashko
2026-03-03 18:35:03 +02:00
committed by GitHub
parent 0b6c1e4165
commit ca754d6589
10 changed files with 730 additions and 8 deletions
+12 -1
View File
@@ -58,7 +58,7 @@ The whole project was built entirely with AI coding agents under my supervision.
### Web / PWA
- Cloudflare Quick Tunnel for instant remote access (`--try-cf-tunnel`)
- Cloudflare tunnel access with two modes: Quick Tunnel (CLI) and Named Tunnel (in-app settings)
- One-scan onboarding with tunnel QR + password URL helpers
- Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI
- Background notifications plus reliable cross-tab session activity tracking
@@ -116,6 +116,8 @@ openchamber stop # Stop server
openchamber update # Update to latest version
```
Named Tunnel mode is configured in-app (Settings -> OpenChamber -> Tunnel). The CLI currently supports Quick Tunnel flags only. `--tunnel <config.yml>` is not supported yet.
### Desktop App (macOS)
Download from [Releases](https://github.com/btriapitsyn/openchamber/releases).
@@ -154,6 +156,15 @@ environment:
| `qr` | Enable tunnel + QR code |
| `password` | Enable tunnel + password in URL |
### Named Cloudflare Tunnel (persistent hostname)
OpenChamber also supports Named Tunnel mode for more reliable long-lived access with your Cloudflare account and custom hostname.
- Configure it in-app at **Settings -> OpenChamber -> Tunnel** and switch mode to **Named**.
- Named tunnels require a domain in your Cloudflare account.
- Cloudflare setup guide: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/
- CLI note: `--tunnel <config.yml>` is not supported yet.
**Data Directory Permission Note:** The `data/` directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions:
```bash
@@ -17,6 +17,8 @@ import { getDefaultTheme } from '@/lib/theme/themes';
import type { ToolPopupContent } from './message/types';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
const withStableStringId = <T extends object>(value: T, id: string): T => {
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
@@ -682,6 +684,530 @@ interface MarkdownRendererProps {
}
const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]';
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
type ParsedFileReference = {
path: string;
line?: number;
column?: number;
};
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
const KNOWN_FILE_BASENAMES = new Set([
'dockerfile',
'makefile',
'readme',
'license',
'.env',
'.gitignore',
'.npmrc',
]);
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
const normalizePath = (value: string): string => {
const source = (value || '').trim();
if (!source) {
return '';
}
const withSlashes = source.replace(/\\/g, '/');
const hadUncPrefix = withSlashes.startsWith('//');
let normalized = withSlashes.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
const isUnixRoot = normalized === '/';
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
if (!isUnixRoot && !isWindowsDriveRoot) {
normalized = normalized.replace(/\/+$/, '');
}
return normalized;
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/')
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|| value.startsWith('//');
};
const toAbsolutePath = (basePath: string, targetPath: string): string => {
const normalizedTarget = normalizePath(targetPath);
if (!normalizedTarget) {
return normalizePath(basePath);
}
if (isAbsolutePath(normalizedTarget)) {
return normalizedTarget;
}
const normalizedBase = normalizePath(basePath);
if (!normalizedBase) {
return normalizedTarget;
}
const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
const stack = baseRemainder.split('/').filter(Boolean);
const parts = normalizedTarget.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') {
continue;
}
if (part === '..') {
if (stack.length > 0) {
stack.pop();
}
continue;
}
stack.push(part);
}
if (isWindowsDriveBase) {
return `${prefix}/${stack.join('/')}`;
}
return `/${stack.join('/')}`;
};
const trimPathCandidate = (value: string): string => {
let next = (value || '').trim();
if (!next) {
return '';
}
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
next = next.slice(1, -1).trim();
}
next = next.replace(/[.,;!?]+$/g, '');
if (next.endsWith(')') && !next.includes('(')) {
next = next.slice(0, -1);
}
if (next.endsWith(']') && !next.includes('[')) {
next = next.slice(0, -1);
}
return next;
};
const stripTrailingReference = (value: string): string => {
let next = trimPathCandidate(value);
if (!next) {
return '';
}
const semicolonIndex = next.indexOf(';');
if (semicolonIndex >= 0) {
next = next.slice(0, semicolonIndex);
}
next = next.replace(/#.*$/, '');
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
if (extensionSuffixMatch) {
next = extensionSuffixMatch[1] ?? next;
}
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
: null;
if (basenameSuffixMatch) {
next = basenameSuffixMatch[1] ?? next;
}
return trimPathCandidate(next);
};
const parseFileReference = (value: string): ParsedFileReference | null => {
const trimmed = trimPathCandidate(value);
if (!trimmed) {
return null;
}
const semicolonIndex = trimmed.indexOf(';');
const withoutSemicolonSuffix = semicolonIndex >= 0
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
: trimmed;
if (!withoutSemicolonSuffix) {
return null;
}
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
if (hashMatch) {
const path = stripTrailingReference(hashMatch[1] ?? '');
const line = Number.parseInt(hashMatch[2] ?? '', 10);
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
if (!path || !Number.isFinite(line)) {
return null;
}
return {
path,
line,
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
};
}
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
if (colonMatch) {
const path = stripTrailingReference(colonMatch[1] ?? '');
const line = Number.parseInt(colonMatch[2] ?? '', 10);
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
if (!path || !Number.isFinite(line)) {
return null;
}
return {
path,
line,
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
};
}
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
if (!pathOnly) {
return null;
}
return { path: pathOnly };
};
const hasFileExtension = (path: string): boolean => {
const base = path.split('/').filter(Boolean).pop() ?? '';
if (!base || base.endsWith('.')) {
return false;
}
return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
};
const isLikelyFilePathValue = (path: string): boolean => {
if (!path || path.startsWith('--') || path.includes('://')) {
return false;
}
if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
return false;
}
const normalized = normalizePath(path);
const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
if (!baseName || baseName === '.' || baseName === '..') {
return false;
}
const base = baseName.toLowerCase();
if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
return true;
}
return hasFileExtension(normalized);
};
const isLikelyFilePath = (value: string): boolean => {
const parsed = parseFileReference(value);
if (!parsed) {
return false;
}
return isLikelyFilePathValue(parsed.path);
};
const extractPathCandidateFromElement = (element: HTMLElement): string => {
if (element.tagName.toLowerCase() === 'a') {
const href = element.getAttribute('href')?.trim();
if (href && isLikelyFilePath(href)) {
return href;
}
}
return (element.textContent || '').trim();
};
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
const parsed = parseFileReference(rawValue);
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
return null;
}
const resolvedPath = isAbsolutePath(parsed.path)
? normalizePath(parsed.path)
: toAbsolutePath(effectiveDirectory, parsed.path);
if (!resolvedPath) {
return null;
}
return {
...parsed,
resolvedPath,
};
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
const normalizedDirectory = normalizePath(effectiveDirectory);
if (normalizedDirectory) {
return normalizedDirectory;
}
const normalizedPath = normalizePath(resolvedPath);
const parent = normalizedPath.replace(/\/[^/]*$/, '');
return parent || normalizedPath;
};
const useFileReferenceInteractions = ({
containerRef,
effectiveDirectory,
readFile,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
effectiveDirectory: string;
readFile?: (path: string) => Promise<{ content: string; path: string }>;
}) => {
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
const annotationPassRef = React.useRef(0);
const annotationDebounceRef = React.useRef<number | null>(null);
const isValidationSweepRunningRef = React.useRef(false);
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let disposed = false;
const isPathResolvable = async (resolvedPath: string): Promise<boolean> => {
const cache = validationCacheRef.current;
if (cache.has(resolvedPath)) {
return cache.get(resolvedPath) === true;
}
const inFlight = inFlightValidationsRef.current.get(resolvedPath);
if (inFlight) {
return inFlight;
}
const checkPromise = (async () => {
try {
if (!readFile) {
return false;
}
await readFile(resolvedPath);
cache.set(resolvedPath, true);
return true;
} catch {
cache.set(resolvedPath, false);
return false;
} finally {
inFlightValidationsRef.current.delete(resolvedPath);
}
})();
inFlightValidationsRef.current.set(resolvedPath, checkPromise);
return checkPromise;
};
const clearCandidateLinkAttrs = (candidate: HTMLElement) => {
candidate.removeAttribute('data-openchamber-file-link');
candidate.removeAttribute('data-openchamber-file-ref');
candidate.removeAttribute('data-openchamber-file-path');
if (candidate.getAttribute('title') === 'Open file') {
candidate.removeAttribute('title');
}
if (candidate.tagName.toLowerCase() !== 'a') {
candidate.removeAttribute('role');
candidate.removeAttribute('tabindex');
}
};
const applyCandidateLinkAttrs = (candidate: HTMLElement, rawCandidate: string, resolvedPath: string) => {
candidate.setAttribute('data-openchamber-file-link', 'true');
candidate.setAttribute('data-openchamber-file-ref', rawCandidate);
candidate.setAttribute('data-openchamber-file-path', resolvedPath);
candidate.setAttribute('title', 'Open file');
if (candidate.tagName.toLowerCase() !== 'a') {
candidate.setAttribute('role', 'button');
candidate.setAttribute('tabindex', '0');
}
};
const runValidationSweep = async (paths: string[], expectedPassID: number) => {
if (isValidationSweepRunningRef.current || paths.length === 0) {
return;
}
isValidationSweepRunningRef.current = true;
const maxConcurrent = 3;
let cursor = 0;
const worker = async () => {
while (!disposed && cursor < paths.length) {
const index = cursor;
cursor += 1;
const pathToCheck = paths[index];
if (!pathToCheck) {
continue;
}
await isPathResolvable(pathToCheck);
}
};
try {
await Promise.all(Array.from({ length: Math.min(maxConcurrent, paths.length) }, () => worker()));
} finally {
isValidationSweepRunningRef.current = false;
}
if (!disposed && annotationPassRef.current === expectedPassID) {
void annotateFileLinks();
}
};
const annotateFileLinks = async () => {
const passID = annotationPassRef.current + 1;
annotationPassRef.current = passID;
const candidates = container.querySelectorAll<HTMLElement>('[data-streamdown="inline-code"], a');
const unresolvedPaths = new Set<string>();
for (const candidate of Array.from(candidates)) {
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
if (!resolved) {
clearCandidateLinkAttrs(candidate);
continue;
}
if (annotationPassRef.current !== passID) {
return;
}
const cachedResult = validationCacheRef.current.get(resolved.resolvedPath);
if (cachedResult === true) {
applyCandidateLinkAttrs(candidate, rawCandidate, resolved.resolvedPath);
continue;
}
clearCandidateLinkAttrs(candidate);
if (cachedResult !== false) {
unresolvedPaths.add(resolved.resolvedPath);
}
}
if (unresolvedPaths.size > 0) {
void runValidationSweep(Array.from(unresolvedPaths), passID);
}
};
const openFileReference = async (sourceElement: HTMLElement): Promise<boolean> => {
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
const resolved = getResolvedReference(raw, effectiveDirectory);
if (!resolved) {
return false;
}
const isResolvable = await isPathResolvable(resolved.resolvedPath);
if (!isResolvable) {
sourceElement.removeAttribute('data-openchamber-file-link');
sourceElement.removeAttribute('data-openchamber-file-ref');
sourceElement.removeAttribute('data-openchamber-file-path');
if (sourceElement.getAttribute('title') === 'Open file') {
sourceElement.removeAttribute('title');
}
return false;
}
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
const uiStore = useUIStore.getState();
if (Number.isFinite(resolved.line ?? Number.NaN)) {
uiStore.openContextFileAtLine(
contextDirectory,
resolved.resolvedPath,
Math.max(1, Math.trunc(resolved.line as number)),
Number.isFinite(resolved.column ?? Number.NaN)
? Math.max(1, Math.trunc(resolved.column as number))
: 1,
);
} else {
uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
}
return true;
};
const handleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const fileRefElement = target.closest(FILE_LINK_SELECTOR);
if (!(fileRefElement instanceof HTMLElement)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openFileReference(fileRefElement);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
return;
}
event.preventDefault();
event.stopPropagation();
void openFileReference(target);
};
void annotateFileLinks();
const observer = new MutationObserver(() => {
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
if (typeof window === 'undefined') {
void annotateFileLinks();
return;
}
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
void annotateFileLinks();
}, 120);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
container.addEventListener('click', handleClick);
container.addEventListener('keydown', handleKeyDown);
return () => {
disposed = true;
annotationPassRef.current += 1;
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
annotationDebounceRef.current = null;
observer.disconnect();
container.removeEventListener('click', handleClick);
container.removeEventListener('keydown', handleKeyDown);
};
}, [containerRef, effectiveDirectory, readFile]);
};
const useMermaidInlineInteractions = ({
containerRef,
@@ -786,9 +1312,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
onShowPopup,
}) => {
const { files } = useRuntimeAPIs();
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -844,11 +1373,13 @@ export const SimpleMarkdownRenderer: React.FC<{
onShowPopup,
allowMermaidWheelZoom = false,
}) => {
const { files } = useRuntimeAPIs();
const renderedContent = React.useMemo(
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
[content, stripFrontmatter],
);
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
useMermaidInlineInteractions({
containerRef: streamdownContainerRef,
@@ -856,6 +1387,7 @@ export const SimpleMarkdownRenderer: React.FC<{
onShowPopup,
allowWheelZoom: allowMermaidWheelZoom,
});
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
const shikiThemes = useMarkdownShikiThemes();
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
@@ -47,6 +47,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { cn } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
type FileNode = {
name: string;
@@ -590,13 +591,19 @@ export const SidebarFilesTree: React.FC = () => {
// --- File operations ---
const handleOpenFile = React.useCallback((node: FileNode) => {
const handleOpenFile = React.useCallback(async (node: FileNode) => {
if (!root) return;
const openValidation = await validateContextFileOpen(files, node.path);
if (!openValidation.ok) {
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
return;
}
setSelectedPath(root, node.path);
addOpenPath(root, node.path);
openContextFile(root, node.path);
}, [addOpenPath, openContextFile, root, setSelectedPath]);
}, [addOpenPath, files, openContextFile, root, setSelectedPath]);
const toggleDirectory = React.useCallback(async (dirPath: string) => {
const normalized = normalizePath(dirPath);
+12 -3
View File
@@ -16,6 +16,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
@@ -25,6 +26,7 @@ import type { DiffViewMode } from '@/components/chat/message/types';
import { PierreDiffViewer } from './PierreDiffViewer';
import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
// Minimum width for side-by-side diff view (px)
const SIDE_BY_SIDE_MIN_WIDTH = 1100;
@@ -908,7 +910,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
}) => {
const { git } = useRuntimeAPIs();
const { git, files } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
@@ -1407,16 +1409,23 @@ export const DiffView: React.FC<DiffViewProps> = ({
? 1
: getFirstChangedModifiedLine(diffForNavigation.original, diffForNavigation.modified));
const absolutePath = toAbsolutePath(effectiveDirectory, filePath);
const openValidation = await validateContextFileOpen(files, absolutePath);
if (!openValidation.ok) {
toast.error(getContextFileOpenFailureMessage(openValidation.reason));
return;
}
openContextFileAtLine(
effectiveDirectory,
toAbsolutePath(effectiveDirectory, filePath),
absolutePath,
resolvedTargetLine,
1,
);
} finally {
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
}
}, [effectiveDirectory, git, openContextFileAtLine, setDiff]);
}, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
if (!selectedFile) {
@@ -649,6 +649,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
// Global mouseup to end drag selection
React.useEffect(() => {
@@ -1842,6 +1844,57 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
textViewMode,
]);
React.useEffect(() => {
if (!pendingFileFocusPath || !root) {
return;
}
const targetPath = normalizePath(pendingFileFocusPath);
if (!targetPath) {
setPendingFileFocusPath(null);
return;
}
if (selectedFile?.path !== targetPath) {
if (selectedPath !== targetPath) {
setSelectedPath(root, targetPath);
}
return;
}
if (fileLoading || loadedFilePath !== targetPath || fileError || isSelectedImage) {
return;
}
if (canEdit && textViewMode !== 'edit') {
setTextViewMode('edit');
return;
}
if (canEdit) {
const view = editorViewRef.current;
if (!view) {
return;
}
view.focus();
}
setPendingFileFocusPath(null);
}, [
canEdit,
fileError,
fileLoading,
isSelectedImage,
loadedFilePath,
pendingFileFocusPath,
root,
selectedFile?.path,
selectedPath,
setPendingFileFocusPath,
setSelectedPath,
textViewMode,
]);
const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => {
if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') {
return;
+11
View File
@@ -823,6 +823,17 @@ html:not(.dark) .chat-scroll {
color: var(--markdown-link-hover, var(--primary));
}
.streamdown-content [data-openchamber-file-link="true"] {
color: var(--markdown-link, var(--primary));
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.streamdown-content [data-openchamber-file-link="true"]:hover {
color: var(--markdown-link-hover, var(--primary));
}
/* Markdown blockquote - use theme colors */
.streamdown-content blockquote {
color: var(--markdown-blockquote, var(--muted-foreground));
@@ -0,0 +1,66 @@
import type { FilesAPI } from '@/lib/api/types';
import { MAX_OPEN_FILE_LINES, countLinesWithLimit } from '@/lib/fileOpenLimits';
export type ContextFileOpenFailureReason = 'too-large' | 'missing' | 'unreadable';
export type ContextFileOpenValidationResult =
| { ok: true }
| { ok: false; reason: ContextFileOpenFailureReason };
const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
const message = error instanceof Error ? error.message : String(error ?? '');
const normalized = message.toLowerCase();
if (
normalized.includes('file not found')
|| normalized.includes('not found')
|| normalized.includes('enoent')
|| normalized.includes('no such file')
|| normalized.includes('does not exist')
) {
return 'missing';
}
return 'unreadable';
};
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
if (files.readFile) {
const result = await files.readFile(path);
return result.content ?? '';
}
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: response.statusText }));
throw new Error((errorPayload as { error?: string }).error || 'Failed to read file');
}
return response.text();
};
export const validateContextFileOpen = async (files: FilesAPI, path: string): Promise<ContextFileOpenValidationResult> => {
try {
const content = await readFileContent(files, path);
const lineCount = countLinesWithLimit(content, MAX_OPEN_FILE_LINES);
if (lineCount > MAX_OPEN_FILE_LINES) {
return { ok: false, reason: 'too-large' };
}
return { ok: true };
} catch (error) {
return { ok: false, reason: classifyReadError(error) };
}
};
export const getContextFileOpenFailureMessage = (reason: ContextFileOpenFailureReason): string => {
if (reason === 'too-large') {
return `File is too large to open (>${MAX_OPEN_FILE_LINES.toLocaleString()} lines)`;
}
if (reason === 'missing') {
return 'File not found';
}
return 'Failed to open file';
};
+19
View File
@@ -0,0 +1,19 @@
export const MAX_OPEN_FILE_LINES = 5_000;
export const countLinesWithLimit = (content: string, limit: number): number => {
if (!content) {
return 1;
}
let lines = 1;
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) === 10) {
lines += 1;
if (lines > limit) {
return lines;
}
}
}
return lines;
};
+10 -1
View File
@@ -476,6 +476,7 @@ interface UIStore {
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
isKeyboardOpen: boolean;
isCommandPaletteOpen: boolean;
@@ -590,6 +591,7 @@ interface UIStore {
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string) => void;
consumePendingDiffFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
@@ -699,6 +701,7 @@ export const useUIStore = create<UIStore>()(
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
isKeyboardOpen: false,
isCommandPaletteOpen: false,
@@ -896,12 +899,13 @@ export const useUIStore = create<UIStore>()(
openContextFile: (directory, filePath) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
const normalizedFilePath = normalizeContextTargetPath(filePath);
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
get().setPendingFileFocusPath(normalizedFilePath);
get().setPendingFileNavigation(null);
},
@@ -915,6 +919,7 @@ export const useUIStore = create<UIStore>()(
}
get().openContextPanelTab(normalizedDirectory, { mode: 'file', targetPath: normalizedFilePath });
get().setPendingFileFocusPath(null);
get().setPendingFileNavigation({
path: normalizedFilePath,
line: normalizedLine,
@@ -1185,6 +1190,10 @@ export const useUIStore = create<UIStore>()(
set({ pendingFileNavigation: navigation });
},
setPendingFileFocusPath: (path) => {
set({ pendingFileFocusPath: path });
},
navigateToDiff: (filePath) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
+6 -1
View File
@@ -31,12 +31,17 @@ openchamber # Start on port 3000
openchamber --port 8080 # Custom port
openchamber --daemon # Background mode
openchamber --ui-password secret # Password-protect UI
openchamber --try-cf-tunnel # Create a Cloudflare Quick Tunnel for remote access
openchamber --try-cf-tunnel --tunnel-qr # Show QR code for easy mobile access
openchamber --try-cf-tunnel --tunnel-password-url # Include password in URL for auto-login
OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server
OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS
openchamber stop # Stop server
openchamber update # Update to latest version
```
Named Tunnel mode is configured in-app (Settings -> OpenChamber -> Tunnel). The CLI currently supports Quick Tunnel flags only. `--tunnel <config.yml>` is not supported yet.
### Environment Variables
- `OPENCODE_HOST` - Full base URL of external OpenCode server, e.g. `http://hostname:4096` or `https://hostname:4096` (overrides `OPENCODE_PORT`)
@@ -61,7 +66,7 @@ openchamber update # Update to latest version
### Web / PWA
- Cloudflare Quick Tunnel for instant remote access (`--try-cf-tunnel`)
- Cloudflare tunnel access with two modes: Quick Tunnel (CLI) and Named Tunnel (in-app settings)
- One-scan onboarding with tunnel QR + password URL helpers
- Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI
- Background notifications plus reliable cross-tab session activity tracking