Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR.
927 lines
36 KiB
TypeScript
927 lines
36 KiB
TypeScript
import React from 'react';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
|
|
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
|
import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight';
|
|
import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSessions } from '@/sync/sync-context';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useGitStore } from '@/stores/useGitStore';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { EditorView } from '@codemirror/view';
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
|
import { parseProjectPlanMarkdown } from '@/lib/openchamberConfig';
|
|
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
|
import { TodoSendDialog, type TodoSendExecution } from '@/components/session/TodoSendDialog';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type PlanViewProps = {
|
|
targetPath?: string | 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;
|
|
};
|
|
|
|
type PlanSendAction = 'improve' | 'implement';
|
|
type PlanSendTarget = 'session' | 'worktree';
|
|
|
|
type PendingPlanSend = {
|
|
action: PlanSendAction;
|
|
target: PlanSendTarget;
|
|
};
|
|
|
|
const normalize = (value: string): string => {
|
|
if (!value) return '';
|
|
const replaced = value.replace(/\\/g, '/');
|
|
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
|
};
|
|
|
|
const joinPath = (base: string, segment: string): string => {
|
|
const normalizedBase = normalize(base);
|
|
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
if (!normalizedBase || normalizedBase === '/') {
|
|
return `/${cleanSegment}`;
|
|
}
|
|
return `${normalizedBase}/${cleanSegment}`;
|
|
};
|
|
|
|
const buildRepoPlanPath = (directory: string, created: number, slug: string): string => {
|
|
return joinPath(joinPath(joinPath(directory, '.opencode'), 'plans'), `${created}-${slug}.md`);
|
|
};
|
|
|
|
const buildHomePlanPath = (created: number, slug: string): string => {
|
|
return `~/.opencode/plans/${created}-${slug}.md`;
|
|
};
|
|
|
|
const resolveTilde = (path: string, homeDir: string | null): string => {
|
|
const trimmed = path.trim();
|
|
if (!trimmed.startsWith('~')) return trimmed;
|
|
if (trimmed === '~') return homeDir || trimmed;
|
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed;
|
|
}
|
|
return trimmed;
|
|
};
|
|
|
|
const toDisplayPath = (resolvedPath: string, options: { currentDirectory: string; homeDirectory: string }): string => {
|
|
const current = normalize(options.currentDirectory);
|
|
const home = normalize(options.homeDirectory);
|
|
const normalized = normalize(resolvedPath);
|
|
|
|
if (current && normalized.startsWith(current + '/')) {
|
|
return normalized.slice(current.length + 1);
|
|
}
|
|
|
|
if (home && normalized === home) {
|
|
return '~';
|
|
}
|
|
|
|
if (home && normalized.startsWith(home + '/')) {
|
|
return `~${normalized.slice(home.length)}`;
|
|
}
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const resolveProjectRefForDirectory = (
|
|
directory: string,
|
|
projects: Array<{ id: string; path: string }>,
|
|
activeProjectId: string | null,
|
|
): { id: string; path: string } | null => {
|
|
const normalized = normalize(directory.trim());
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const activeProject = activeProjectId
|
|
? projects.find((project) => project.id === activeProjectId) ?? null
|
|
: null;
|
|
|
|
if (activeProject?.path) {
|
|
const activePath = normalize(activeProject.path);
|
|
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
|
|
return { id: activeProject.id, path: activeProject.path };
|
|
}
|
|
}
|
|
|
|
const match = projects
|
|
.filter((project) => {
|
|
const projectPath = normalize(project.path);
|
|
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
|
|
})
|
|
.sort((left, right) => normalize(right.path).length - normalize(left.path).length)[0];
|
|
|
|
return match ? { id: match.id, path: match.path } : null;
|
|
};
|
|
|
|
type SelectedLineRange = {
|
|
start: number;
|
|
end: number;
|
|
};
|
|
|
|
export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, onNavigatedToChat }) => {
|
|
const { t } = useI18n();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const createSession = useSessionUIStore((state) => state.createSession);
|
|
const initializeNewOpenChamberSession = useSessionUIStore((state) => state.initializeNewOpenChamberSession);
|
|
const sendMessage = useSessionUIStore((state) => state.sendMessage);
|
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
|
const sessions = useSessions();
|
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
const gitDirectories = useGitStore((state) => state.directories);
|
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
|
const runtimeApis = useRuntimeAPIs();
|
|
const { isMobile } = useDeviceInfo();
|
|
const { currentTheme } = useThemeSystem();
|
|
|
|
const session = React.useMemo(() => {
|
|
if (!currentSessionId) return null;
|
|
return sessions.find((s) => s.id === currentSessionId) ?? null;
|
|
}, [currentSessionId, sessions]);
|
|
|
|
const sessionDirectory = React.useMemo(() => {
|
|
const raw = typeof session?.directory === 'string' ? session.directory : '';
|
|
return normalize(raw || '');
|
|
}, [session?.directory]);
|
|
const projectDirectory = React.useMemo(
|
|
() => normalize(effectiveDirectory || sessionDirectory),
|
|
[effectiveDirectory, sessionDirectory],
|
|
);
|
|
const currentProjectRef = React.useMemo(
|
|
() => resolveProjectRefForDirectory(projectDirectory, projects, activeProjectId),
|
|
[activeProjectId, projectDirectory, projects],
|
|
);
|
|
const canCreateWorktree = React.useMemo(
|
|
() => (currentProjectRef ? gitDirectories.get(currentProjectRef.path)?.isGitRepo === true : false),
|
|
[currentProjectRef, gitDirectories],
|
|
);
|
|
const [pendingPlanSend, setPendingPlanSend] = React.useState<PendingPlanSend | null>(null);
|
|
const [isPlanSendSubmitting, setIsPlanSendSubmitting] = React.useState(false);
|
|
|
|
const [resolvedPath, setResolvedPath] = React.useState<string | null>(null);
|
|
const displayPath = React.useMemo(() => {
|
|
if (!resolvedPath || !sessionDirectory || !homeDirectory) {
|
|
return resolvedPath;
|
|
}
|
|
return toDisplayPath(resolvedPath, { currentDirectory: sessionDirectory, homeDirectory });
|
|
}, [resolvedPath, sessionDirectory, homeDirectory]);
|
|
const [content, setContent] = React.useState<string>('');
|
|
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
|
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
|
const [saveError, setSaveError] = React.useState<string | null>(null);
|
|
const planFileLabel = React.useMemo(() => {
|
|
return displayPath ? displayPath.split('/').pop() || t('planView.file.defaultName') : t('planView.file.defaultName');
|
|
}, [displayPath, t]);
|
|
const parsedTitle = React.useMemo(() => {
|
|
if (!content.trim()) {
|
|
return t('planView.title.default');
|
|
}
|
|
return parseProjectPlanMarkdown(content).title || t('planView.title.default');
|
|
}, [content, t]);
|
|
const sendPromptTitle = React.useMemo(() => parsedTitle.trim() || t('planView.title.default'), [parsedTitle, t]);
|
|
const [loading, setLoading] = React.useState(false);
|
|
const [copiedContent, setCopiedContent] = React.useState(false);
|
|
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
|
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
|
const editorViewRef = React.useRef<EditorView | null>(null);
|
|
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
|
|
|
|
React.useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(MD_VIEWER_MODE_KEY);
|
|
if (!stored) return;
|
|
const parsed = JSON.parse(stored) as unknown;
|
|
if (parsed === 'preview' || parsed === 'edit') {
|
|
setMdViewMode(parsed);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
|
setMdViewMode(mode);
|
|
try {
|
|
localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
const isSelectingRef = React.useRef(false);
|
|
const selectionStartRef = React.useRef<number | null>(null);
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const handleGlobalMouseUp = () => {
|
|
isSelectingRef.current = false;
|
|
selectionStartRef.current = null;
|
|
setIsDragging(false);
|
|
};
|
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
|
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
}, []);
|
|
|
|
const extractSelectedCode = React.useCallback((text: string, range: SelectedLineRange): string => {
|
|
const lines = text.split('\n');
|
|
const startLine = Math.max(1, range.start);
|
|
const endLine = Math.min(lines.length, range.end);
|
|
if (startLine > endLine) return '';
|
|
return lines.slice(startLine - 1, endLine).join('\n');
|
|
}, []);
|
|
|
|
const commentController = useInlineCommentController<SelectedLineRange>({
|
|
source: 'plan',
|
|
fileLabel: planFileLabel,
|
|
language: resolvedPath ? getLanguageFromExtension(resolvedPath) || 'markdown' : 'markdown',
|
|
getCodeForRange: (range) => extractSelectedCode(content, normalizeLineRange(range)),
|
|
toStoreRange: (range) => ({ startLine: range.start, endLine: range.end }),
|
|
fromDraftRange: (draft) => ({ start: draft.startLine, end: draft.endLine }),
|
|
});
|
|
|
|
const {
|
|
drafts: planFileDrafts,
|
|
commentText,
|
|
setCommentText,
|
|
editingDraftId,
|
|
setSelection: setCommentSelection,
|
|
saveComment,
|
|
cancel,
|
|
reset,
|
|
startEdit,
|
|
deleteDraft,
|
|
} = commentController;
|
|
|
|
React.useEffect(() => {
|
|
setLineSelection(null);
|
|
reset();
|
|
}, [content, reset]);
|
|
|
|
React.useEffect(() => {
|
|
setCommentSelection(lineSelection);
|
|
}, [lineSelection, setCommentSelection]);
|
|
|
|
const handleCancelComment = React.useCallback(() => {
|
|
setLineSelection(null);
|
|
cancel();
|
|
}, [cancel]);
|
|
|
|
const handleSaveComment = React.useCallback((textToSave: string, rangeOverride?: { start: number; end: number }) => {
|
|
if (rangeOverride) {
|
|
setLineSelection(rangeOverride);
|
|
}
|
|
saveComment(textToSave, rangeOverride ?? lineSelection ?? undefined);
|
|
setLineSelection(null);
|
|
}, [lineSelection, saveComment]);
|
|
|
|
React.useEffect(() => {
|
|
if (!lineSelection) return;
|
|
|
|
if (isMobile && !editingDraftId) {
|
|
// Input handles mobile scroll/focus behavior.
|
|
}
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
if (
|
|
target.closest('[data-comment-card="true"]') ||
|
|
target.closest('[data-comment-input="true"]') ||
|
|
target.closest('.oc-block-widget')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (target.closest('.cm-gutterElement')) return;
|
|
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
|
|
|
if (!commentText.trim()) {
|
|
setLineSelection(null);
|
|
cancel();
|
|
}
|
|
};
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
document.addEventListener('click', handleClickOutside);
|
|
}, 100);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
document.removeEventListener('click', handleClickOutside);
|
|
};
|
|
}, [cancel, commentText, editingDraftId, isMobile, lineSelection]);
|
|
|
|
const editorFontSize = useUIStore((state) => state.editorFontSize);
|
|
|
|
const editorExtensions = React.useMemo(() => {
|
|
// Shiki token colors only for code files; markdown keeps the lezer
|
|
// highlighter (markdown-aware bold headings etc., and no Shiki view to match).
|
|
const shikiLanguage = resolvedPath ? getLanguageFromExtension(resolvedPath) : null;
|
|
const useShiki = Boolean(shikiLanguage) && shikiLanguage !== 'markdown';
|
|
const extensions = [createFlexokiCodeMirrorTheme(currentTheme, useShiki ? { syntaxColors: false, fontSize: editorFontSize } : { fontSize: editorFontSize })];
|
|
const language = languageByExtension(resolvedPath || 'plan.md');
|
|
if (language) {
|
|
extensions.push(language);
|
|
}
|
|
if (useShiki && shikiLanguage) {
|
|
extensions.push(shikiHighlightExtension({
|
|
language: shikiLanguage,
|
|
themeName: currentTheme.metadata.id,
|
|
theme: getResolvedShikiTheme(currentTheme),
|
|
}));
|
|
}
|
|
extensions.push(EditorView.lineWrapping);
|
|
return extensions;
|
|
}, [currentTheme, resolvedPath, editorFontSize]);
|
|
|
|
React.useEffect(() => {
|
|
// Saved project plans opened via context panel should work even when session plan mode is off.
|
|
if (!planModeEnabled && !targetPath) {
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const readText = async (path: string): Promise<string> => {
|
|
if (runtimeApis.files?.readFile) {
|
|
const result = await runtimeApis.files.readFile(path);
|
|
return result?.content ?? '';
|
|
}
|
|
|
|
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
|
if (runtimeFiles?.readFile) {
|
|
const result = await runtimeFiles.readFile(path, { optional: true });
|
|
return result?.content ?? '';
|
|
}
|
|
|
|
const response = await runtimeFetch(`/api/fs/read?path=${encodeURIComponent(path)}&optional=true`, {
|
|
// Avoid conditional requests (304 + empty body).
|
|
cache: 'no-store',
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to read plan file (${response.status})`);
|
|
}
|
|
return response.text();
|
|
};
|
|
|
|
const run = async () => {
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
setSaveError(null);
|
|
|
|
if (targetPath) {
|
|
setLoading(true);
|
|
try {
|
|
const text = await readText(targetPath);
|
|
if (cancelled) return;
|
|
setResolvedPath(targetPath);
|
|
setContent(text);
|
|
} catch {
|
|
if (cancelled) return;
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!session?.slug || !session?.time?.created || !sessionDirectory) {
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const repoPath = buildRepoPlanPath(sessionDirectory, session.time.created, session.slug);
|
|
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);
|
|
resolved = repoPath;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
if (!resolved) {
|
|
try {
|
|
text = await readText(homePath);
|
|
resolved = homePath;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (cancelled) return;
|
|
|
|
if (!resolved || text === null) {
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
return;
|
|
}
|
|
|
|
setResolvedPath(resolved);
|
|
setContent(text);
|
|
} catch {
|
|
if (cancelled) return;
|
|
setResolvedPath(null);
|
|
setContent('');
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void run();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [homeDirectory, planModeEnabled, runtimeApis.files, sessionDirectory, session?.slug, session?.time?.created, targetPath]);
|
|
|
|
React.useEffect(() => {
|
|
if (!resolvedPath) {
|
|
setSaveError(null);
|
|
return;
|
|
}
|
|
|
|
const controller = window.setTimeout(async () => {
|
|
setSaveError(null);
|
|
try {
|
|
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'));
|
|
}
|
|
}, 350);
|
|
|
|
return () => {
|
|
window.clearTimeout(controller);
|
|
};
|
|
}, [content, resolvedPath, runtimeApis.files, t]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const routeToChat = React.useCallback(() => {
|
|
setActiveMainTab('chat');
|
|
setSessionSwitcherOpen(false);
|
|
onNavigatedToChat?.();
|
|
}, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]);
|
|
|
|
const handleConfirmPlanSend = React.useCallback(
|
|
async (execution: TodoSendExecution) => {
|
|
if (!currentProjectRef || !pendingPlanSend) {
|
|
return;
|
|
}
|
|
|
|
const visiblePrompt = await renderMagicPrompt(
|
|
pendingPlanSend.action === 'improve' ? 'plan.improve.visible' : 'plan.implement.visible',
|
|
{
|
|
plan_title: sendPromptTitle,
|
|
},
|
|
);
|
|
const instructionsText = await renderMagicPrompt(
|
|
pendingPlanSend.action === 'improve' ? 'plan.improve.instructions' : 'plan.implement.instructions',
|
|
{
|
|
plan_title: sendPromptTitle,
|
|
plan_path: resolvedPath ?? '',
|
|
},
|
|
);
|
|
const syntheticParts = [{ synthetic: true as const, text: instructionsText }];
|
|
setIsPlanSendSubmitting(true);
|
|
|
|
try {
|
|
routeToChat();
|
|
|
|
let sessionId: string | null = null;
|
|
let directoryHint: string | null = currentProjectRef.path;
|
|
|
|
if (pendingPlanSend.target === 'worktree') {
|
|
if (!canCreateWorktree) {
|
|
return;
|
|
}
|
|
const created = await createWorktreeSessionForNewBranch(currentProjectRef.path, generateBranchName());
|
|
if (!created?.id) {
|
|
return;
|
|
}
|
|
sessionId = created.id;
|
|
directoryHint = created.path;
|
|
} else {
|
|
const sessionResult = await createSession(undefined, currentProjectRef.path, null);
|
|
if (!sessionResult?.id) {
|
|
return;
|
|
}
|
|
sessionId = sessionResult.id;
|
|
directoryHint = sessionResult.directory ?? currentProjectRef.path;
|
|
initializeNewOpenChamberSession(sessionResult.id, useConfigStore.getState().agents ?? []);
|
|
}
|
|
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
const selectionState = useSelectionStore.getState();
|
|
selectionState.saveSessionModelSelection(sessionId, execution.providerID, execution.modelID);
|
|
if (execution.agent.trim()) {
|
|
selectionState.saveSessionAgentSelection(sessionId, execution.agent);
|
|
selectionState.saveAgentModelForSession(sessionId, execution.agent, execution.providerID, execution.modelID);
|
|
selectionState.saveAgentModelVariantForSession(
|
|
sessionId,
|
|
execution.agent,
|
|
execution.providerID,
|
|
execution.modelID,
|
|
execution.variant || undefined,
|
|
);
|
|
}
|
|
|
|
setCurrentSession(sessionId, directoryHint);
|
|
// "Run as goal" rides the same arm mechanism as the composer target
|
|
// button; set explicitly either way so a stray armed flag cannot
|
|
// leak into a non-goal plan send. The objective override carries the
|
|
// plan substance — "Implement this plan: X" alone would give the
|
|
// progress audit nothing to judge against. Plans that exceed the
|
|
// objective limit are distilled into completion criteria by the
|
|
// small model (the working agent always reads the full plan from
|
|
// its file); on distillation failure a head+tail excerpt keeps the
|
|
// intent (top) and acceptance criteria (bottom), sacrificing the
|
|
// implementation middle the agent reads from the file anyway.
|
|
// Oversized objectives (huge plans) are distilled into audit
|
|
// criteria inside setSessionGoal — the shared path for every goal
|
|
// 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.',
|
|
'',
|
|
content,
|
|
].join('\n')
|
|
: null;
|
|
useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective);
|
|
await sendMessage(
|
|
visiblePrompt,
|
|
execution.providerID,
|
|
execution.modelID,
|
|
execution.agent.trim() || undefined,
|
|
undefined,
|
|
undefined,
|
|
syntheticParts,
|
|
execution.variant || undefined,
|
|
);
|
|
|
|
setPendingPlanSend(null);
|
|
} finally {
|
|
setIsPlanSendSubmitting(false);
|
|
}
|
|
},
|
|
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
|
);
|
|
|
|
const blockWidgets = React.useMemo(() => {
|
|
return buildCodeMirrorCommentWidgets({
|
|
drafts: planFileDrafts,
|
|
editingDraftId,
|
|
commentText,
|
|
onTextChange: setCommentText,
|
|
selection: lineSelection,
|
|
isDragging,
|
|
fileLabel: planFileLabel,
|
|
newWidgetId: 'plan-new-comment-input',
|
|
mapDraftToRange: (draft) => ({ start: draft.startLine, end: draft.endLine }),
|
|
onSave: handleSaveComment,
|
|
onCancel: handleCancelComment,
|
|
onEdit: (draft) => {
|
|
startEdit(draft);
|
|
setLineSelection({ start: draft.startLine, end: draft.endLine });
|
|
},
|
|
onDelete: deleteDraft,
|
|
});
|
|
}, [commentText, deleteDraft, editingDraftId, handleCancelComment, handleSaveComment, isDragging, lineSelection, planFileDrafts, planFileLabel, setCommentText, startEdit]);
|
|
|
|
return (
|
|
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
|
|
<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>
|
|
{saveError ? (
|
|
<div className="typography-micro text-[color:var(--status-error)] truncate" title={saveError}>
|
|
{t('planView.error.saveFailed')}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{resolvedPath ? (
|
|
<div className="flex items-center gap-1">
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
aria-label={t('planView.actions.improvePlanAria')}
|
|
disabled={!content.trim()}
|
|
>
|
|
<Icon name="loop-right-ai" className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8}>{t('planView.actions.improve')}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'improve', target: 'session' })}>
|
|
{t('planView.actions.sendToNewSession')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setPendingPlanSend({ action: 'improve', target: 'worktree' })}
|
|
disabled={!canCreateWorktree}
|
|
>
|
|
{t('planView.actions.sendToNewWorktreeSession')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
aria-label={t('planView.actions.implementPlanAria')}
|
|
disabled={!content.trim()}
|
|
>
|
|
<Icon name="code-ai" className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8}>{t('planView.actions.implement')}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setPendingPlanSend({ action: 'implement', target: 'session' })}>
|
|
{t('planView.actions.sendToNewSession')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setPendingPlanSend({ action: 'implement', target: 'worktree' })}
|
|
disabled={!canCreateWorktree}
|
|
>
|
|
{t('planView.actions.sendToNewWorktreeSession')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<PreviewToggleButton
|
|
currentMode={mdViewMode}
|
|
onToggle={() => saveMdViewMode(mdViewMode === 'preview' ? 'edit' : 'preview')}
|
|
/>
|
|
{mdViewMode === 'preview' && showMessageTTSButtons && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
aria-label={isTTSPlaying ? t('planView.tts.stopSpeaking') : t('planView.tts.readAloud')}
|
|
onClick={() => {
|
|
if (isTTSPlaying) {
|
|
stopTTS();
|
|
} else if (content.trim()) {
|
|
void playTTS(content);
|
|
}
|
|
}}
|
|
>
|
|
{isTTSPlaying ? (
|
|
<Icon name="stop" className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
) : (
|
|
<Icon name="volume-up" className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent sideOffset={8}>
|
|
{isTTSPlaying ? t('planView.tts.stopSpeaking') : t('planView.tts.readAloud')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={async () => {
|
|
const result = await copyTextToClipboard(content);
|
|
if (result.ok) {
|
|
setCopiedContent(true);
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
}
|
|
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
|
setCopiedContent(false);
|
|
}, 1200);
|
|
} else {
|
|
// ignored
|
|
}
|
|
}}
|
|
className="h-5 w-5 p-0"
|
|
title={t('planView.actions.copyPlanContents')}
|
|
aria-label={t('planView.actions.copyPlanContents')}
|
|
>
|
|
{copiedContent ? (
|
|
<Icon name="check" className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
) : (
|
|
<Icon name="clipboard" className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<TodoSendDialog
|
|
open={pendingPlanSend !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open && !isPlanSendSubmitting) {
|
|
setPendingPlanSend(null);
|
|
}
|
|
}}
|
|
target={pendingPlanSend?.target ?? 'session'}
|
|
projectDirectory={currentProjectRef?.path ?? null}
|
|
submitting={isPlanSendSubmitting}
|
|
allowRunAsGoal
|
|
onConfirm={handleConfirmPlanSend}
|
|
/>
|
|
|
|
<div className="flex-1 min-h-0 min-w-0 relative">
|
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
|
{loading ? (
|
|
<div className="p-3 typography-ui text-muted-foreground">{t('planView.state.loading')}</div>
|
|
) : (
|
|
<div className="relative h-full">
|
|
<div className="h-full">
|
|
{mdViewMode === 'preview' ? (
|
|
<div className="h-full overflow-auto p-3">
|
|
<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('planView.error.previewUnavailable')}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{t('planView.error.switchToEditMode')}
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<SimpleMarkdownRenderer content={content} className="typography-markdown-body" enableFileReferences={false} />
|
|
</ErrorBoundary>
|
|
</div>
|
|
) : (
|
|
<div className="relative h-full" ref={editorWrapperRef}>
|
|
<CodeMirrorEditor
|
|
value={content}
|
|
onChange={setContent}
|
|
readOnly={false}
|
|
className="h-full"
|
|
extensions={editorExtensions}
|
|
onViewReady={(view) => { editorViewRef.current = view; }}
|
|
onViewDestroy={() => { editorViewRef.current = null; }}
|
|
blockWidgets={blockWidgets}
|
|
highlightLines={lineSelection
|
|
? {
|
|
start: Math.min(lineSelection.start, lineSelection.end),
|
|
end: Math.max(lineSelection.start, lineSelection.end),
|
|
}
|
|
: undefined}
|
|
lineNumbersConfig={{
|
|
domEventHandlers: {
|
|
mousedown: (view, line, event) => {
|
|
if (!(event instanceof MouseEvent)) return false;
|
|
if (event.button !== 0) return false;
|
|
event.preventDefault();
|
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
|
|
|
if (
|
|
lineSelection &&
|
|
!event.shiftKey &&
|
|
Math.min(lineSelection.start, lineSelection.end) === lineNumber &&
|
|
Math.max(lineSelection.start, lineSelection.end) === lineNumber
|
|
) {
|
|
setLineSelection(null);
|
|
cancel();
|
|
isSelectingRef.current = false;
|
|
selectionStartRef.current = null;
|
|
setIsDragging(false);
|
|
return true;
|
|
}
|
|
|
|
if (isMobile && lineSelection && !event.shiftKey) {
|
|
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
|
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
|
setLineSelection({ start, end });
|
|
isSelectingRef.current = false;
|
|
selectionStartRef.current = null;
|
|
setIsDragging(false);
|
|
return true;
|
|
}
|
|
|
|
isSelectingRef.current = true;
|
|
selectionStartRef.current = lineNumber;
|
|
setIsDragging(true);
|
|
|
|
if (lineSelection && event.shiftKey) {
|
|
const start = Math.min(lineSelection.start, lineNumber);
|
|
const end = Math.max(lineSelection.end, lineNumber);
|
|
setLineSelection({ start, end });
|
|
} else {
|
|
setLineSelection({ start: lineNumber, end: lineNumber });
|
|
}
|
|
|
|
return true;
|
|
},
|
|
mouseover: (view, line, event) => {
|
|
if (!(event instanceof MouseEvent)) return false;
|
|
if (event.buttons !== 1) return false;
|
|
if (!isSelectingRef.current || selectionStartRef.current === null) return false;
|
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
|
setLineSelection({ start, end });
|
|
setIsDragging(true);
|
|
return false;
|
|
},
|
|
mouseup: () => {
|
|
isSelectingRef.current = false;
|
|
selectionStartRef.current = null;
|
|
setIsDragging(false);
|
|
return false;
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ScrollableOverlay>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|