Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
@@ -1775,6 +1775,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
scrollToFile(value);
}, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]);
// Step review to the adjacent changed file (alt+arrow): selects, expands
// a collapsed section, and scrolls to it. Window-level because the diff
// surface has no persistent focus target; guarded off editable fields.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return;
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const target = event.target;
if (target instanceof HTMLElement && (
target.isContentEditable
|| target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.closest('[role="dialog"]')
)) {
return;
}
if (changedFiles.length === 0) return;
const delta = event.key === 'ArrowDown' ? 1 : -1;
const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1;
const nextIndex = index === -1
? (delta > 0 ? 0 : changedFiles.length - 1)
: index + delta;
const next = changedFiles[nextIndex];
if (!next) return;
event.preventDefault();
handleSelectFileAndScroll(next.path);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [changedFiles, displayFile, handleSelectFileAndScroll]);
const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
+166 -90
View File
@@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownPreviewSearch } from './MarkdownPreviewSearch';
import { PreviewToggleButton } from './PreviewToggleButton';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
@@ -45,7 +46,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { cn, getRevealLabelKey } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
@@ -75,7 +76,9 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { isEditableEventTarget } from '@/hooks/keyboard-shortcut-dom';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
@@ -967,6 +970,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [copiedContent, setCopiedContent] = React.useState(false);
const [copiedPath, setCopiedPath] = React.useState(false);
const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false);
// In-preview find for the rendered Markdown preview (Ctrl/Cmd+F).
const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false);
const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0);
const mdPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
// Give the rendered preview keyboard focus (without scrolling it) unless the
// user is typing somewhere else, so Cmd/Ctrl+F opens the preview find bar
// right after a Markdown file opens and after any click inside it.
const focusMdPreviewContainer = React.useCallback((event?: React.MouseEvent<HTMLDivElement>) => {
const container = event?.currentTarget ?? mdPreviewContainerRef.current;
if (!container) return;
const active = document.activeElement;
if (active && active !== document.body && active !== container) {
if (isEditableEventTarget(active)) return;
if (container.contains(active)) return;
}
container.focus({ preventScroll: true });
}, []);
const mdFullscreenPreviewContainerRef = React.useRef<HTMLDivElement | null>(null);
const canCreateFile = Boolean(files.writeFile);
const canCreateFolder = Boolean(files.createDirectory);
@@ -1032,7 +1053,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
@@ -1759,35 +1779,45 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setAutoSaveStatus('idle');
}, [selectedFile?.path]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!hasModifier(e)) {
useKeybinds({
save_file: (event) => {
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
// Cancel pending auto-save because the explicit save should run immediately.
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (!isSaving) {
void saveDraft().then((saved) => {
if (!saved) return;
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
},
find_in_file: (event) => {
if (!(event.target instanceof Node)) return false;
// Rendered Markdown preview: open the in-preview find bar instead of the
// editor search. Registered through the keybind schema rather than a raw
// window listener so it cannot swallow Cmd/Ctrl+F app-wide while a
// Markdown file happens to be selected behind another panel tab.
if (isMarkdown && getMdViewMode() === 'preview') {
if (isMobile) return false;
const previewContainer = isFullscreen
? mdFullscreenPreviewContainerRef.current
: mdPreviewContainerRef.current;
if (!previewContainer?.contains(event.target)) return false;
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
return;
}
if (e.key.toLowerCase() === 's') {
e.preventDefault();
// Cancel pending auto-save; user wants immediate save
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (!isSaving) {
void saveDraft().then((saved) => {
if (!saved) return;
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
} else if (e.key.toLowerCase() === 'f') {
e.preventDefault();
setIsSearchOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]);
if (!editorWrapperRef.current?.contains(event.target)) return false;
setIsSearchOpen(true);
},
});
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
const loadId = activeFileLoadIdRef.current + 1;
@@ -2490,6 +2520,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return mdViewMode;
}, [mdViewMode]);
const mdPreviewFocusTargetPath = selectedFile && isMarkdown && getMdViewMode() === 'preview' && !fileLoading
? selectedFile.path
: null;
React.useEffect(() => {
if (!mdPreviewFocusTargetPath || isMobile) return;
focusMdPreviewContainer();
}, [focusMdPreviewContainer, isFullscreen, isMobile, mdPreviewFocusTargetPath]);
const saveJsonViewMode = React.useCallback((mode: 'tree' | 'text') => {
setJsonViewMode(mode);
try {
@@ -2906,42 +2944,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
React.useEffect(() => {
useKeybind('open_go_to_line', (event) => {
if (!canEdit || textViewMode !== 'edit' || isMobile) {
return;
return false;
}
const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides);
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) return false;
if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) {
return;
}
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]'));
if (isTypingTarget && !isEditorTarget) return false;
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
const isTypingTarget = Boolean(
target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')
);
if (isTypingTarget && !isEditorTarget) {
return;
}
const activeElement = document.activeElement as Element | null;
const editorHasFocus = Boolean(activeElement?.closest('.cm-editor'));
if (!editorHasFocus) {
return;
}
if (eventMatchesShortcut(event, goToLineCombo)) {
event.preventDefault();
setIsGoToLineOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canEdit, isMobile, shortcutOverrides, textViewMode]);
setIsGoToLineOpen(true);
});
const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -3196,6 +3213,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
const docked = layout === 'docked';
const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file'));
const wrapperCls = docked
? 'pointer-events-auto flex flex-wrap items-center gap-1'
: 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm';
@@ -3225,14 +3243,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<Icon name="check" className="size-3.5" />
{t('filesView.editor.saved')}
</span>
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }),
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }),
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })}
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut })}
aria-label={t('filesView.editor.saveAria', { shortcut: saveShortcut })}
>
<Icon name="save-3" className="size-4" />
</Button>
@@ -3393,6 +3411,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
)}
{isMarkdown && getMdViewMode() === 'preview' && (
withTooltip(t('filesView.editor.findInFile'),
<Button
variant="ghost"
size="sm"
onClick={() => {
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
}}
className="size-6 p-0 text-foreground opacity-100 transition-opacity hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
title={t('filesView.editor.findInFile')}
>
<Icon name="search" className="size-4" />
</Button>
)
)}
{isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && (
<Tooltip>
<TooltipTrigger asChild>
@@ -3869,34 +3904,55 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
</ErrorBoundary>
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-3" ref={markdownPreviewRef}>
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
<div className="relative h-full min-h-0">
<div
className="oc-file-preview h-full overflow-auto p-3 outline-none"
// Focusable so Cmd/Ctrl+F reaches the find bar: the keybind only
// fires when the event target sits inside this container, and a
// plain div never holds focus. -1 keeps it out of the tab order.
tabIndex={-1}
onMouseDown={focusMdPreviewContainer}
ref={(node) => {
markdownPreviewRef.current = node;
mdPreviewContainerRef.current = node;
}}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
filePath={selectedFile.path}
fileContent={fileContent}
/>
</ErrorBoundary>
{fileContent.length > 500 * 1024 && (
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
</div>
)}
<ErrorBoundary
fallback={
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
<div className="mb-1 font-medium text-destructive">{t('filesView.error.previewUnavailable')}</div>
<div className="text-sm text-muted-foreground">
{t('filesView.error.switchToEditMode')}
</div>
</div>
}
>
<SimpleMarkdownRenderer
content={fileContent}
className="typography-markdown-body"
stripFrontmatter
enableFileReferences={false}
/>
</ErrorBoundary>
</div>
{!isFullscreen && (
<MarkdownPreviewSearch
containerRef={mdPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
/>
)}
</div>
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
isHtmlAssetAuthLoading ? (
@@ -4240,7 +4296,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : null}
</div>
) : isMarkdown && getMdViewMode() === 'preview' ? (
<div className="oc-file-preview h-full overflow-auto p-4" ref={markdownPreviewRef}>
// The find bar is a sibling of the scroll container, never a child:
// inside it, its own "1/3" and "No matches" text would be walked and
// highlighted by the search it drives.
<div className="relative h-full min-h-0">
<div
className="oc-file-preview h-full overflow-auto p-4 outline-none"
tabIndex={-1}
onMouseDown={focusMdPreviewContainer}
ref={(node) => {
markdownPreviewRef.current = node;
mdFullscreenPreviewContainerRef.current = node;
}}
>
{selectedFile ? (
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
@@ -4271,6 +4339,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
</ErrorBoundary>
</div>
<MarkdownPreviewSearch
containerRef={mdFullscreenPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
className="right-4 top-16"
/>
</div>
) : canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
) : (
+4 -2
View File
@@ -1372,8 +1372,10 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}
try {
await git.checkoutBranch(gitDirectory, normalized);
toast.success(t('gitView.toast.checkedOut', { name: normalized }));
// Picking a remote-tracking branch checks out the local branch that
// tracks it, so report the branch the repository actually landed on.
const result = await git.checkoutBranch(gitDirectory, normalized);
toast.success(t('gitView.toast.checkedOut', { name: result?.branch || normalized }));
await refreshStatusAndBranches();
await refreshLog();
} catch (err) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
import { describe, expect, test } from 'bun:test';
import { findMatchRanges } from './markdownPreviewFind';
describe('findMatchRanges', () => {
test('returns no ranges for an empty or whitespace-only query', () => {
expect(findMatchRanges('hello world', '')).toEqual([]);
expect(findMatchRanges('hello world', ' ')).toEqual([]);
});
test('returns no ranges when the query does not occur', () => {
expect(findMatchRanges('hello world', 'nope')).toEqual([]);
});
test('finds all non-overlapping occurrences', () => {
expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([
{ start: 0, end: 3 },
{ start: 31, end: 34 },
]);
});
test('matches case-insensitively', () => {
expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([
{ start: 0, end: 5 },
{ start: 6, end: 11 },
{ start: 12, end: 17 },
]);
});
test('scans non-overlapping matches like standard find-in-page', () => {
expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]);
});
test('trims the query before matching', () => {
expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]);
});
test('handles a query longer than the text', () => {
expect(findMatchRanges('abc', 'abcdef')).toEqual([]);
});
});
@@ -0,0 +1,371 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { findMatchRanges } from './markdownPreviewFind';
/**
* In-preview text search for the rendered Markdown file preview.
*
* The preview renders as plain DOM (no iframe/shadow root), so browser-native
* find works on web — but the Electron desktop shell has no find-in-page
* implementation at all, and CodeMirror's search only exists in edit mode.
* This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact
* search bar with match highlighting, navigation, and a live count, scoped to
* the preview container.
*
* The rendered DOM is owned by the markdown renderer (block-level morphdom
* reconciliation), so highlights are re-applied whenever the renderer mutates
* the container (theme or content changes) via a MutationObserver; mutations
* produced by this widget itself are ignored.
*/
const MARK_ATTR = 'data-md-find';
const CURRENT_MARK_ATTR = 'data-md-find-current';
const MARK_CLASS = 'rounded-[2px] bg-status-warning/30 text-foreground';
const CURRENT_MARK_CLASS = 'rounded-[2px] bg-status-warning/60 text-foreground';
/** Keystrokes re-walk the whole preview, so coalesce bursts of typing. */
const SEARCH_DEBOUNCE_MS = 120;
const isMarkElement = (node: Node): boolean => {
return node instanceof Element && node.hasAttribute(MARK_ATTR);
};
/** True when this widget's own highlight surgery produced the record. */
const isSelfProducedMutation = (record: MutationRecord): boolean => {
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
return true;
}
return [...record.addedNodes].some((node) => isMarkElement(node));
};
const clearHighlights = (container: HTMLElement): void => {
const touchedParents = new Set<Node>();
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
touchedParents.add(parent);
});
// Once per affected parent instead of once per mark. Merging the split text
// nodes back together is safe under the renderer's morphdom path: it diffs
// against a tree freshly parsed from HTML, where the merged single text node
// is exactly the shape it expects.
touchedParents.forEach((parent) => parent.normalize());
};
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
clearHighlights(container);
const normalized = query.trim().toLowerCase();
if (!normalized) {
return [];
}
const marks: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) {
return NodeFilter.FILTER_REJECT;
}
// Skipping svg (mermaid) keeps the highlight pass from corrupting
// diagram rendering; script/style content is never visible anyway.
if (parent.closest('svg, script, style')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes: Text[] = [];
while (walker.nextNode()) {
const node = walker.currentNode;
if (node instanceof Text) {
textNodes.push(node);
}
}
for (const node of textNodes) {
const text = node.nodeValue ?? '';
if (!text) {
continue;
}
const ranges = findMatchRanges(text, normalized);
if (ranges.length === 0) {
continue;
}
const parent = node.parentNode;
if (!parent) {
continue;
}
const fragment = document.createDocumentFragment();
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
fragment.appendChild(document.createTextNode(text.slice(cursor, range.start)));
}
const mark = document.createElement('mark');
mark.setAttribute(MARK_ATTR, '');
mark.className = MARK_CLASS;
mark.textContent = text.slice(range.start, range.end);
fragment.appendChild(mark);
marks.push(mark);
cursor = range.end;
}
if (cursor < text.length) {
fragment.appendChild(document.createTextNode(text.slice(cursor)));
}
parent.replaceChild(fragment, node);
}
return marks;
};
type MarkdownPreviewSearchProps = {
/** The scrollable preview container whose rendered text is searched. */
containerRef: React.RefObject<HTMLDivElement | null>;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Bumped every time the find shortcut is pressed to re-focus the input. */
focusNonce: number;
/** Layout overrides for the floating bar (position, offsets). */
className?: string;
};
export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
containerRef,
open,
onOpenChange,
focusNonce,
className,
}) => {
const { t } = useI18n();
const [query, setQuery] = React.useState('');
const [total, setTotal] = React.useState(0);
const [index, setIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const marksRef = React.useRef<HTMLElement[]>([]);
const queryRef = React.useRef(query);
queryRef.current = query;
const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Focus returns here when the bar closes, so Escape does not strand focus.
const returnFocusRef = React.useRef<HTMLElement | null>(null);
/**
* `keepIndex` distinguishes a new query (start at match 1) from a re-search
* of the same query after the renderer re-morphed the container: a theme
* toggle or content refresh must not yank the reader back to match 1.
*/
const runSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
const container = containerRef.current;
if (!container) {
marksRef.current = [];
setTotal(0);
setIndex(0);
return;
}
marksRef.current = applySearch(container, nextQuery);
const nextTotal = marksRef.current.length;
setTotal(nextTotal);
setIndex((current) => {
if (!keepIndex || nextTotal === 0) {
return 0;
}
return Math.min(current, nextTotal - 1);
});
}, [containerRef]);
const scheduleSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
runSearch(nextQuery, keepIndex);
}, SEARCH_DEBOUNCE_MS);
}, [runSearch]);
React.useEffect(() => () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}, []);
const close = React.useCallback(() => {
onOpenChange(false);
const target = returnFocusRef.current;
returnFocusRef.current = null;
if (target?.isConnected) {
target.focus();
}
}, [onOpenChange]);
// Re-apply highlights when the renderer re-morphs the container (theme or
// content changes), ignoring mutations this widget produces itself. Only
// active while the bar is open; closing clears the highlights.
React.useEffect(() => {
const container = containerRef.current;
if (!open || !container) {
return;
}
const observer = new MutationObserver((records) => {
if (!queryRef.current.trim()) {
return;
}
// Per record, not per batch: the renderer can deliver a genuine mutation
// in the same batch as one of ours, and `.some` would swallow it.
const rendererTouched = records.some((record) => !isSelfProducedMutation(record));
if (!rendererTouched) {
return;
}
// Debounced like typing — a morph batch would otherwise pay a full
// TreeWalker plus DOM surgery per mutation batch.
scheduleSearch(queryRef.current, true);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
clearHighlights(container);
};
}, [containerRef, open, scheduleSearch]);
// Focus the input when the bar opens, remembering what to restore on close.
React.useEffect(() => {
if (!open) {
return;
}
const previous = document.activeElement;
if (previous instanceof HTMLElement && !returnFocusRef.current) {
returnFocusRef.current = previous;
}
inputRef.current?.focus();
}, [open]);
// Pressing the find shortcut again re-focuses and re-selects the query.
React.useEffect(() => {
if (open && focusNonce > 0) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [open, focusNonce]);
// Keep the current-match highlight and scroll it into view.
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => {
mark.removeAttribute(CURRENT_MARK_ATTR);
mark.className = MARK_CLASS;
});
if (total === 0) {
return;
}
const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)];
if (!current) {
return;
}
current.setAttribute(CURRENT_MARK_ATTR, '');
current.className = CURRENT_MARK_CLASS;
current.scrollIntoView({ block: 'nearest' });
}, [containerRef, index, total]);
const goToNext = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current + 1) % total));
}, [total]);
const goToPrevious = React.useCallback(() => {
setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total));
}, [total]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
goToPrevious();
} else {
goToNext();
}
} else if (event.key === 'Escape') {
event.preventDefault();
close();
}
}, [close, goToNext, goToPrevious]);
if (!open) {
return null;
}
return (
<div className={cn('absolute right-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border/60 bg-[var(--surface-elevated)] px-1.5 py-1 shadow-lg', className)}>
<Icon name="search" className="ml-0.5 size-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(event) => {
setQuery(event.target.value);
scheduleSearch(event.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={t('filesView.preview.find.placeholder')}
aria-label={t('filesView.preview.find.placeholder')}
className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56"
/>
<span
className="min-w-12 px-1 text-center typography-micro text-muted-foreground tabular-nums"
aria-live="polite"
aria-label={total > 0
? t('filesView.preview.find.countAria', { current: index + 1, total })
: t('filesView.preview.find.noMatches')}
>
{query.trim() && total === 0
? t('filesView.preview.find.noMatches')
: total > 0
? `${index + 1}/${total}`
: ''}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToPrevious}
title={t('filesView.preview.find.previousAria')}
aria-label={t('filesView.preview.find.previousAria')}
disabled={total === 0}
>
<Icon name="arrow-up" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={goToNext}
title={t('filesView.preview.find.nextAria')}
aria-label={t('filesView.preview.find.nextAria')}
disabled={total === 0}
>
<Icon name="arrow-down" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="size-6 p-0 text-muted-foreground"
onClick={close}
title={t('filesView.preview.find.closeAria')}
aria-label={t('filesView.preview.find.closeAria')}
>
<Icon name="close" className="size-3.5" />
</Button>
</div>
);
};
+260 -74
View File
@@ -38,7 +38,10 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { EditorView } from '@codemirror/view';
import { copyTextToClipboard } from '@/lib/clipboard';
import { generateBranchName } from '@/lib/git/branchNameGenerator';
import { fetchProjectPlan, parsePlanMarkdown } from '@/lib/projectContextApi';
import { fetchProjectPlan, parsePlanMarkdown, resolveProjectContextId, type SavedProjectPlanTarget } from '@/lib/projectContextApi';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { createPlanSaveQueue } from '@/lib/planSaveQueue';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
@@ -49,9 +52,12 @@ import { useI18n } from '@/lib/i18n';
type PlanViewProps = {
targetPath?: string | null;
/** Saved project plan to open. Project plans are server-owned and addressed
by id; they never carry a client-visible filesystem path. */
projectPlanId?: string | null;
/** Saved project plan to open, with the project that owns it. The owner is
part of the prop so the view never guesses it from the current directory:
plan tabs outlive directory changes (persisted context tabs, mobile
overlays), and for managed chats the owner is not a registered project a
directory lookup could ever find. */
savedProjectPlan?: SavedProjectPlanTarget | null;
/** Called after a send action routes the user to the chat hosts that show
PlanView in an overlay (mobile fullscreen surface) close it here. */
onNavigatedToChat?: () => void;
@@ -149,12 +155,16 @@ const resolveProjectRefForDirectory = (
return match ? { id: match.id, path: match.path } : null;
};
const subscribeActiveRuntimeKey = (onStoreChange: () => void): (() => void) => {
return subscribeRuntimeEndpointChanged(() => onStoreChange());
};
type SelectedLineRange = {
start: number;
end: number;
};
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPlanId = null, onNavigatedToChat }) => {
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, savedProjectPlan = null, onNavigatedToChat }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const createSession = useSessionUIStore((state) => state.createSession);
@@ -170,6 +180,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
const effectiveDirectory = useEffectiveDirectory() ?? '';
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const runtimeApis = useRuntimeAPIs();
const activeRuntimeKey = React.useSyncExternalStore(subscribeActiveRuntimeKey, getRuntimeKey, getRuntimeKey);
const { isMobile } = useDeviceInfo();
const { currentTheme } = useThemeSystem();
@@ -190,9 +201,37 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
[activeProjectId, projectDirectory, projects],
);
// Destructured to primitives so the load/save effects key on stable values
// instead of a descriptor object rebuilt on every parent render.
const savedPlanProjectId = savedProjectPlan?.projectRef.id ?? null;
const savedPlanProjectPath = savedProjectPlan?.projectRef.path ?? null;
const savedPlanProjectRef = React.useMemo(
() => savedPlanProjectId && savedPlanProjectPath
? { id: savedPlanProjectId, path: savedPlanProjectPath }
: null,
[savedPlanProjectId, savedPlanProjectPath],
);
const savedPlanId = savedProjectPlan?.planId ?? null;
// Stable logical identity, composed from primitives: an effect keyed on the
// descriptor object would reload — and flush — the same plan whenever a
// parent rebuilds the owner object with identical values.
const savedPlanKey = savedPlanProjectRef && savedPlanId
? JSON.stringify(['saved-plan', activeRuntimeKey, resolveProjectContextId(savedPlanProjectRef), savedPlanId])
: null;
// Managed chats have no project directory to create a session in: their
// sessions live in per-session directories under the chats root, which
// createSession cannot prepare. Until a managed-chat send path exists,
// Improve/Implement stay unavailable for plans stored under the Chats
// owner — an OpenCode session created directly in the shared root would
// break the managed-chats model.
const isManagedChatPlan = savedPlanProjectRef?.id === CHAT_DRAFT_PROJECT_ID;
const canCreateWorktree = React.useMemo(
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
[currentProjectRef, gitDirectories],
() => {
// Worktree creation follows the session the plan would be sent to.
const sendTarget = savedPlanProjectRef ?? currentProjectRef;
return sendTarget ? gitDirectories.get(sendTarget.path)?.isGitRepo === true : false;
},
[currentProjectRef, gitDirectories, savedPlanProjectRef],
);
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
@@ -202,7 +241,6 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
// `resolvedPath` so nothing downstream can mistake a project plan for a file
// the user could open, edit, or be shown a path for.
const [loadedProjectPlanId, setLoadedProjectPlanId] = React.useState<string | null>(null);
const savePlan = useProjectContextStore((state) => state.savePlan);
const hasDocument = Boolean(resolvedPath) || Boolean(loadedProjectPlanId);
const displayPath = React.useMemo(() => {
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
@@ -214,6 +252,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const [saveError, setSaveError] = React.useState<string | null>(null);
const [loadError, setLoadError] = React.useState<string | null>(null);
const planFileLabel = React.useMemo(() => {
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
}, [displayPath, t]);
@@ -381,9 +420,96 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
return extensions;
}, [currentTheme, resolvedPath, editorFontSize]);
// Pending-save bookkeeping for the open document. One ref record, not state:
// debounced writes and close-time flushes must read the newest buffer and
// revision without another render. `editRevision` advances on every editor
// change; `savedRevision` only after a successful write of that exact
// revision, so a slow in-flight save can never mark newer edits as saved.
// `key` and `runtimeKey` make every write self-identifying: content never
// crosses documents or runtimes, no matter when a queued write settles.
const docRef = React.useRef<{
key: string | null;
target: SavedProjectPlanTarget | { filePath: string } | null;
content: string;
editRevision: number;
savedRevision: number;
runtimeKey: string;
}>({ key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' });
const saveQueue = React.useState(createPlanSaveQueue)[0];
// Filesystem writes keep the runtime adapter precedence the view always
// used: the active RuntimeAPIs first, the registry as fallback.
const writeDocument = React.useCallback(async (target: NonNullable<typeof docRef.current['target']>, text: string): Promise<void> => {
if ('filePath' in target) {
const files = runtimeApis.files ?? getRegisteredRuntimeAPIs()?.files;
if (files?.writeFile) {
const result = await files.writeFile(target.filePath, text);
if (!result?.success) {
throw new Error('Plan file write failed');
}
return;
}
const response = await runtimeFetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: target.filePath, content: text }),
});
if (!response.ok) {
throw new Error(`Failed to write plan file (${response.status})`);
}
return;
}
const saved = await useProjectContextStore.getState().savePlan(target.projectRef, target.planId, text);
if (!saved) {
throw new Error('Plan save rejected: the plan no longer exists');
}
}, [runtimeApis.files]);
const writeDocumentRef = React.useRef(writeDocument);
writeDocumentRef.current = writeDocument;
// Queue any unflushed edits. Runs on document switches and on unmount, both
// of which cancel the debounced save — without this the last 350ms of typing
// is silently dropped. The queue orders it behind any write already in
// flight for the same document, and the captured runtime key stops content
// from one host being written into another after a runtime switch.
const scheduleSave = React.useCallback(() => {
const doc = docRef.current;
if (!doc.key || !doc.target || doc.editRevision <= doc.savedRevision) {
return;
}
const captured = {
key: doc.key,
target: doc.target,
content: doc.content,
revision: doc.editRevision,
runtimeKey: doc.runtimeKey,
write: writeDocumentRef.current,
};
saveQueue.schedule(captured.key, captured.revision, async () => {
if (getRuntimeKey() !== captured.runtimeKey) {
// The runtime switched while this write waited: writing through the
// new connection would land one host's edits on another.
return;
}
await captured.write(captured.target, captured.content);
const current = docRef.current;
if (current.key === captured.key) {
current.savedRevision = Math.max(current.savedRevision, captured.revision);
// A recovered save clears the stale failure banner.
setSaveError(null);
}
}).catch((error) => {
if (docRef.current.key === captured.key) {
setSaveError(error instanceof Error ? error.message : 'Plan save failed');
}
});
}, [saveQueue]);
React.useEffect(() => {
// Saved project plans opened via context panel should work even when session plan mode is off.
if (!planModeEnabled && !targetPath && !projectPlanId) {
if (!planModeEnabled && !targetPath && !savedPlanId) {
scheduleSave();
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
@@ -416,31 +542,49 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
};
const run = async () => {
// Flush the outgoing document before the bookkeeping is replaced, so
// edits typed within the debounce window survive a plan switch. React
// reuses this component instance across saved-plan tabs.
scheduleSave();
docRef.current = { key: null, target: null, content: '', editRevision: 0, savedRevision: 0, runtimeKey: '' };
setResolvedPath(null);
setLoadedProjectPlanId(null);
setContent('');
setSaveError(null);
setLoadError(null);
if (projectPlanId) {
if (!currentProjectRef) {
return;
}
if (savedPlanId && savedPlanProjectRef && savedPlanKey) {
// A plan re-opened while its own flush is still writing must read the
// post-write state, not race it. The queue reset afterwards is safe:
// every write for this key has settled, and the reloaded document
// restarts its revision counter at zero.
await saveQueue.pendingFor(savedPlanKey);
if (cancelled) return;
saveQueue.reset(savedPlanKey);
setLoading(true);
try {
const plan = await fetchProjectPlan(currentProjectRef, projectPlanId);
const plan = await fetchProjectPlan(savedPlanProjectRef, savedPlanId);
if (cancelled) return;
if (!plan) {
// The plan or its markdown is gone. Leave the view empty and
// unsaveable rather than presenting an editor that would recreate
// a document the user deleted.
setSaveError(t('planView.error.loadFailed'));
setLoadError('Plan not found');
return;
}
docRef.current = {
key: savedPlanKey,
target: { projectRef: savedPlanProjectRef, planId: savedPlanId },
content: plan.raw,
editRevision: 0,
savedRevision: 0,
runtimeKey: activeRuntimeKey,
};
setContent(plan.raw);
setLoadedProjectPlanId(projectPlanId);
setLoadedProjectPlanId(savedPlanId);
} catch (error) {
if (cancelled) return;
setSaveError(error instanceof Error ? error.message : t('planView.error.loadFailed'));
setLoadError(error instanceof Error ? error.message : 'Plan load failed');
} finally {
if (!cancelled) setLoading(false);
}
@@ -448,10 +592,22 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
}
if (targetPath) {
const fileKey = JSON.stringify(['plan-file', activeRuntimeKey, targetPath]);
await saveQueue.pendingFor(fileKey);
if (cancelled) return;
saveQueue.reset(fileKey);
setLoading(true);
try {
const text = await readText(targetPath);
if (cancelled) return;
docRef.current = {
key: fileKey,
target: { filePath: targetPath },
content: text,
editRevision: 0,
savedRevision: 0,
runtimeKey: activeRuntimeKey,
};
setResolvedPath(targetPath);
setContent(text);
} catch {
@@ -477,10 +633,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
const homePath = resolveTilde(buildHomePlanPath(session.time.created, session.slug), homeDirectory || null);
let resolved: string | null = null;
let text: string | null = null;
try {
text = await readText(repoPath);
await readText(repoPath);
resolved = repoPath;
} catch {
// ignore
@@ -488,7 +643,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
if (!resolved) {
try {
text = await readText(homePath);
await readText(homePath);
resolved = homePath;
} catch {
// ignore
@@ -497,12 +652,26 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
if (cancelled) return;
if (!resolved || text === null) {
if (!resolved) {
setResolvedPath(null);
setContent('');
return;
}
const sessionFileKey = JSON.stringify(['plan-file', activeRuntimeKey, resolved]);
await saveQueue.pendingFor(sessionFileKey);
if (cancelled) return;
const text = await readText(resolved);
if (cancelled) return;
saveQueue.reset(sessionFileKey);
docRef.current = {
key: sessionFileKey,
target: { filePath: resolved },
content: text,
editRevision: 0,
savedRevision: 0,
runtimeKey: activeRuntimeKey,
};
setResolvedPath(resolved);
setContent(text);
} catch {
@@ -519,55 +688,42 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
return () => {
cancelled = true;
};
}, [currentProjectRef, homeDirectory, planModeEnabled, projectPlanId, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, t, targetPath]);
}, [activeRuntimeKey, homeDirectory, planModeEnabled, runtimeApis.files, savedPlanId, savedPlanKey, savedPlanProjectRef, saveQueue, scheduleSave, session?.slug, session?.time?.created, sessionDirectory, targetPath]);
// Synchronous buffer tracking: if an edit and an unmount land in the same
// batch, the passive content effect would never run and a flush would save
// a stale buffer.
const handleContentChange = React.useCallback((next: string) => {
docRef.current.content = next;
docRef.current.editRevision += 1;
setContent(next);
}, []);
// The debounced write and the close/switch flush go through the same queue
// (scheduleSave), so two saves of one document can never complete out of
// order and a flush never duplicates a debounce of the same revision.
React.useEffect(() => {
if (!resolvedPath && !loadedProjectPlanId) {
return;
}
const controller = window.setTimeout(async () => {
setSaveError(null);
try {
if (loadedProjectPlanId) {
if (!currentProjectRef) {
throw new Error(t('planView.error.writeFailed'));
}
const saved = await savePlan(currentProjectRef, loadedProjectPlanId, content);
if (!saved) {
throw new Error(t('planView.error.writeFailed'));
}
return;
}
if (!resolvedPath) {
return;
}
if (runtimeApis.files?.writeFile) {
const result = await runtimeApis.files.writeFile(resolvedPath, content);
if (!result?.success) {
throw new Error(t('planView.error.writeFailed'));
}
} else {
const response = await runtimeFetch('/api/fs/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: resolvedPath, content }),
});
if (!response.ok) {
throw new Error(t('planView.error.writePlanFileFailed', { status: response.status }));
}
}
} catch (error) {
setSaveError(error instanceof Error ? error.message : t('planView.error.saveFailed'));
}
const controller = window.setTimeout(() => {
scheduleSave();
}, 350);
return () => {
window.clearTimeout(controller);
};
}, [content, currentProjectRef, loadedProjectPlanId, resolvedPath, runtimeApis.files, savePlan, t]);
}, [content, loadedProjectPlanId, resolvedPath, scheduleSave]);
// Closing the view inside the 350ms debounce window would drop the last
// edits: the cleanup above cancels the timer. Same for switching documents,
// which the load effect handles before replacing the bookkeeping.
React.useEffect(() => {
return () => {
scheduleSave();
};
}, [scheduleSave]);
React.useEffect(() => {
return () => {
@@ -584,7 +740,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
const handleConfirmPlanSend = React.useCallback(
async (execution: TodoSendExecution) => {
if (!currentProjectRef || !pendingPlanSend) {
// A saved plan sends against its own project — the one it is stored
// under — not against whatever directory the viewer is currently in.
// For filesystem plans those are the same directory.
const sendTargetProject = savedPlanProjectRef ?? currentProjectRef;
if (!sendTargetProject || !pendingPlanSend || isManagedChatPlan) {
return;
}
@@ -601,32 +761,45 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
plan_path: resolvedPath ?? '',
},
);
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
// Saved project plans have no file path for the agent to read. Without
// this the instructions say "read that file" with an empty path and the
// plan contents never reach the session, so the plan substance rides
// along in the synthetic message instead.
const planSubstance = resolvedPath
? instructionsText
: [
instructionsText,
'',
'The plan is not stored as a file in the repository and has no file path. Its full current contents follow below this note and are the source of truth for the plan. Where the instructions above refer to the plan file, treat the plan as stored in OpenChamber project knowledge (it is edited through the OpenChamber UI): propose plan revisions as plan text in the chat rather than editing a file.',
'',
content,
].join('\n');
const syntheticParts = [{ synthetic: true as const, text: planSubstance }];
setIsPlanSendSubmitting(true);
try {
routeToChat();
let sessionId: string | null = null;
let directoryHint: string | null = currentProjectRef.path;
let directoryHint: string | null = sendTargetProject.path;
if (pendingPlanSend.target === 'worktree') {
if (!canCreateWorktree) {
return;
}
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
const created = await createWorktreeSessionForNewBranch(sendTargetProject.path, generateBranchName());
if (!created?.id) {
return;
}
sessionId = created.id;
directoryHint = created.path;
} else {
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
const sessionResult = await createSession(undefined, sendTargetProject.path, null);
if (!sessionResult?.id) {
return;
}
sessionId = sessionResult.id;
directoryHint = sessionResult.directory ?? currentProjectRef.path;
directoryHint = sessionResult.directory ?? sendTargetProject.path;
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
}
@@ -664,8 +837,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
// source. Here we only compose header + full content.
const goalObjective = execution.runAsGoal === true
? [
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
'Re-read that file for full details — it is the source of truth.',
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ' (the full plan follows)'}.`,
resolvedPath
? 'Re-read that file for full details — it is the source of truth.'
: 'The full plan follows in this message and is the source of truth.',
'',
content,
].join('\n')
@@ -687,7 +862,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
setIsPlanSendSubmitting(false);
}
},
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, isManagedChatPlan, pendingPlanSend, resolvedPath, routeToChat, savedPlanProjectRef, sendMessage, sendPromptTitle, setCurrentSession]
);
const blockWidgets = React.useMemo(() => {
@@ -716,6 +891,11 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
<div className="min-w-0 flex-1">
<div className="typography-ui-label font-medium truncate">{parsedTitle}</div>
{loadError ? (
<div className="typography-micro text-[color:var(--status-error)] truncate" title={loadError}>
{t('planView.error.loadFailed')}
</div>
) : null}
{saveError ? (
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
{t('planView.error.saveFailed')}
@@ -733,7 +913,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
size="sm"
className="h-5 w-5 p-0"
aria-label={t('planView.actions.improvePlanAria')}
disabled={!content.trim()}
disabled={!content.trim() || isManagedChatPlan}
>
<Icon name="loop-right-ai" className="size-4" />
</Button>
@@ -742,7 +922,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}
disabled={isManagedChatPlan}
>
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
@@ -762,7 +945,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
size="sm"
className="h-5 w-5 p-0"
aria-label={t('planView.actions.implementPlanAria')}
disabled={!content.trim()}
disabled={!content.trim() || isManagedChatPlan}
>
<Icon name="code-ai" className="size-4" />
</Button>
@@ -771,7 +954,10 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
<DropdownMenuItem
onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}
disabled={isManagedChatPlan}
>
{t('planView.actions.sendToNewSession')}
</DropdownMenuItem>
<DropdownMenuItem
@@ -853,7 +1039,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
}
}}
target={pendingPlanSend?.target ?? 'session'}
projectDirectory={currentProjectRef?.path ?? null}
projectDirectory={savedPlanProjectRef?.path ?? currentProjectRef?.path ?? null}
submitting={isPlanSendSubmitting}
allowRunAsGoal
onConfirm={handleConfirmPlanSend}
@@ -885,7 +1071,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={setContent}
onChange={handleContentChange}
readOnly={false}
className="h-full"
extensions={editorExtensions}
@@ -1,5 +1,9 @@
import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils';
import { cn } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -187,6 +191,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsPageRaw = useUIStore((state) => state.settingsPage);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
@@ -209,6 +214,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const [pendingSearchItemId, setPendingSearchItemId] = React.useState<string | null>(null);
const [activeSearchResultIndex, setActiveSearchResultIndex] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const shouldFocusMobilePageContentRef = React.useRef(false);
const searchResultRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const activeSearchResultIndexRef = React.useRef(0);
const keyboardSearchNavigationRef = React.useRef(false);
@@ -728,7 +734,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: showBackButton
? t('settings.view.actions.backToSettings')
: t('settings.view.actions.closeSettings');
const shortcutKey = getModifierLabel();
const openSettingsCombo = getEffectiveShortcutCombo(
'open_settings',
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
);
const closeSettingsTitle = openSettingsCombo
? t('settings.view.actions.closeSettingsWithShortcut', {
shortcut: formatShortcutForDisplay(openSettingsCombo),
})
: t('settings.view.actions.closeSettings');
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
@@ -751,12 +765,30 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
}, [runtimeCtx.isVSCode]);
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
shouldFocusMobilePageContentRef.current = true;
setMobileStage('page-content');
if (settingsSlug === 'skills.installed') {
pushMobileSplitDetailHistory(settingsSlug);
}
}, [pushMobileSplitDetailHistory, settingsSlug]);
React.useEffect(() => {
if (!isMobile || mobileStage !== 'page-content' || !shouldFocusMobilePageContentRef.current) {
return;
}
shouldFocusMobilePageContentRef.current = false;
const frame = window.requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLElement>('[data-settings-page-heading]')
?.focus({ preventScroll: true });
});
return () => {
window.cancelAnimationFrame(frame);
};
}, [isMobile, mobileStage, settingsSlug]);
const handleBack = React.useCallback(() => {
if (backButtonTargetsPageSidebar) {
const currentDetail = typeof window !== 'undefined'
@@ -932,7 +964,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
{(page.slug === 'tunnel' || page.slug === 'integrations') && (
{page.slug === 'tunnel' && (
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
{t('settings.view.badge.beta')}
</span>
@@ -1077,7 +1109,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
title={closeSettingsTitle}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
@@ -1105,7 +1137,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
title={closeSettingsTitle}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
@@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
type TerminalViewProps = {
visible?: boolean;
@@ -968,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span>
<span className="text-xs font-medium">{formatShortcutForDisplay('ctrl')}</span>
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
</Button>
<Button
@@ -981,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('alt')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span>
<span className="text-xs font-medium">{formatShortcutForDisplay('alt')}</span>
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
</Button>
<Button
@@ -22,8 +22,6 @@ import { useI18n } from '@/lib/i18n';
/** Max file size in bytes (10MB) */
const MAX_FILE_SIZE = 10 * 1024 * 1024;
/** Max number of concurrent runs */
const MAX_MODELS = 5;
/** Attached file for agent manager */
interface AttachedFile {
@@ -132,11 +130,8 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
}, [projectRef]);
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
if (selectedModels.length >= MAX_MODELS) {
return;
}
setSelectedModels((prev) => [...prev, model]);
}, [selectedModels.length]);
}, []);
const handleRemoveModel = React.useCallback((index: number) => {
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
@@ -529,7 +524,6 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
onUpdate={handleUpdateModel}
minModels={1}
addButtonLabel={t('agentManager.empty.models.addModel')}
maxModels={5}
/>
</div>
@@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n';
interface CommitInputProps {
value: string;
onChange: (value: string) => void;
onSubmit?: () => void;
placeholder?: string;
disabled?: boolean;
hasTouchInput?: boolean;
@@ -18,6 +19,7 @@ const MAX_HEIGHT = 200;
export const CommitInput: React.FC<CommitInputProps> = ({
value,
onChange,
onSubmit,
placeholder,
disabled = false,
hasTouchInput = false,
@@ -58,6 +60,12 @@ export const CommitInput: React.FC<CommitInputProps> = ({
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
onSubmit?.();
}
}}
placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')}
rows={1}
disabled={disabled}
@@ -68,6 +68,9 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<CommitInput
value={commitMessage}
onChange={onCommitMessageChange}
onSubmit={() => {
if (canCommit && !isGeneratingMessage) onCommit();
}}
placeholder={t('gitView.commit.messagePlaceholder')}
disabled={commitAction !== null}
hasTouchInput={hasTouchInput}
@@ -166,4 +166,68 @@ describe('assignLanes', () => {
const bottomStub = cResult.connectors.find((c) => c.type === 'bottom-stub');
expect(bottomStub).toBeTruthy();
});
test('handles double merge of same branch with single commit between merges (screenshot case)', () => {
// Repro for screenshot: admin branch forked from base, 3 commits (48f6,c55f,2949),
// merged into main at 594c, then one more admin commit 3257 whose parent is
// the same 2949 as the merge's second parent (criss-cross), then merged again at a37.
// Order is topo-order as returned by `git log --all --topo-order` for that DAG.
const commits = [
makeCommit('a37', ['594c', '3257']),
makeCommit('3257', ['2949']),
makeCommit('594c', ['base', '2949']),
makeCommit('2949', ['c55f']),
makeCommit('c55f', ['48f6']),
makeCommit('48f6', ['base']),
makeCommit('base', []),
];
const result = assignLanes(commits);
// Should use only 2 lanes (main=0, admin=1) throughout no lane jump to 2
const maxLane = Math.max(...result.map((r) => r.lane));
expect(maxLane).toBe(1);
// The intermediate admin commit 3257 should be on admin lane
const c3257 = result.find((r) => r.commit.hash === '3257')!;
expect(c3257.lane).toBe(1);
// Second merge (594c) must reuse admin lane rather than opening a new one,
// so its extra parent lane is 1 (reused) not a fresh lane.
const m1 = result.find((r) => r.commit.hash === '594c')!;
const m1BranchOut = m1.connectors.find((c) => c.type === 'branch-out')!;
expect(m1BranchOut.toLane).toBe(1);
// Crucial: at the merge row, the reused admin lane must keep its vertical
// passing segment for continuity between 3257 above and 2949 below.
// Without this, a gap appears between those rows (the screenshot bug).
const m1Passing = m1.connectors.filter((c) => c.type === 'passing');
expect(m1Passing.some((c) => c.fromLane === 1)).toBe(true);
// Top merge also branch-out to admin lane
const m2 = result.find((r) => r.commit.hash === 'a37')!;
const m2BranchOut = m2.connectors.find((c) => c.type === 'branch-out')!;
expect(m2BranchOut.toLane).toBe(1);
// Top merge's admin lane is new, so no passing at that row (branch starts there)
expect(m2.connectors.some((c) => c.type === 'passing' && c.fromLane === 1)).toBe(false);
// Base should merge both lanes cleanly
const base = result.find((r) => r.commit.hash === 'base')!;
const mergeIns = base.connectors.filter((c) => c.type === 'merge-in');
expect(mergeIns.length).toBe(1);
});
test('reuses lane when merge second parent already active (no extra lane)', () => {
const commits = [
makeCommit('m2', ['m1', 'a3']),
makeCommit('a3', ['common']),
makeCommit('m1', ['base', 'common']),
makeCommit('common', ['base']),
makeCommit('base', []),
];
const result = assignLanes(commits);
// m1 should reuse lane 1 (where a3 lives) rather than opening lane 2
const m1 = result.find((r) => r.commit.hash === 'm1')!;
expect(m1.connectors.find((c) => c.type === 'branch-out')!.toLane).toBe(1);
expect(Math.max(...result.map((r) => r.lane))).toBe(1);
});
});
@@ -101,6 +101,7 @@ export function assignLanes(commits: GitLogEntry[]): LanedCommit[] {
// Open new lanes for additional parents (merge commits)
const extraParentLanes: number[] = [];
const extraParentIsNew = new Set<number>();
for (let p = 1; p < commit.parents.length; p++) {
const parentHash = commit.parents[p];
// Check if another lane is already waiting for this parent
@@ -109,10 +110,16 @@ export function assignLanes(commits: GitLogEntry[]): LanedCommit[] {
extraParentLanes.push(existingLane);
} else {
const freeLane = activeLanes.indexOf(null);
const newLane = freeLane !== -1 ? freeLane : activeLanes.length;
activeLanes[newLane] = parentHash;
if (newLane === activeLanes.length) activeLanes.push(parentHash);
extraParentLanes.push(newLane);
if (freeLane !== -1) {
activeLanes[freeLane] = parentHash;
extraParentLanes.push(freeLane);
extraParentIsNew.add(freeLane);
} else {
const newLane = activeLanes.length;
activeLanes.push(parentHash);
extraParentLanes.push(newLane);
extraParentIsNew.add(newLane);
}
}
}
@@ -151,11 +158,14 @@ export function assignLanes(commits: GitLogEntry[]): LanedCommit[] {
});
}
// Passing-through lanes (active but not this commit's lane or extra parent lanes)
// Passing-through lanes (active but not this commit's lane or newly-opened extra parent lanes)
// Reused extra parents already have an active lane above the merge, so they must keep their
// vertical passing segment for continuity (otherwise a gap appears between
// the commit above and the merge row, as in the double-merge-of-same-branch case).
for (let lane = 0; lane < activeLanes.length; lane++) {
if (activeLanes[lane] === null) continue;
if (lane === assignedLane) continue;
if (extraParentLanes.includes(lane)) continue;
if (extraParentIsNew.has(lane)) continue;
connectors.push({
fromLane: lane,
toLane: lane,
@@ -0,0 +1,23 @@
/**
* Case-insensitive substring match ranges over a single text string, using
* the same non-overlapping `String.prototype.indexOf` scan semantics as
* standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]).
*/
export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => {
const normalized = query.trim().toLowerCase();
const ranges: Array<{ start: number; end: number }> = [];
if (!normalized) {
return ranges;
}
const lower = text.toLowerCase();
let cursor = 0;
while (true) {
const index = lower.indexOf(normalized, cursor);
if (index === -1) {
break;
}
ranges.push({ start: index, end: index + normalized.length });
cursor = index + normalized.length;
}
return ranges;
};