fix(chat): resolve parent project for worktree-session messages

Save-as-plan and Add-to-notes actions were disabled on messages whose session
ran in a worktree, because session.directory alone does not reflect the
worktree attachment — the authoritative cwd lives in session-worktree-store.

Use useEffectiveDirectory (attachment cwd > worktree metadata > session.directory)
and resolve the parent project via the worktree map the sidebar already builds,
extracted into lib/projectResolution so session-ui-store and chat actions share
one resolver. No extra git calls.
This commit is contained in:
Bohdan Triapitsyn
2026-04-18 16:05:55 +03:00
parent 4aa0dcdc1c
commit f04d732e15
4 changed files with 89 additions and 128 deletions
@@ -38,6 +38,8 @@ import { StaticToolRow } from './parts/ProgressiveGroup';
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
import TurnActivity from '../components/TurnActivity';
import { createProjectPlanFile } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useSessions } from '@/sync/sync-context';
type SubtaskPartLike = Part & {
@@ -79,38 +81,6 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
return `${providerID}/${modelID}`;
};
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
const resolveProjectRefForDirectory = (
directory: string,
projects: Array<{ id: string; path: string }>,
activeProjectId: string | null,
): { id: string; path: string } | null => {
const normalized = normalizePath(directory.trim());
if (!normalized) {
return null;
}
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (activeProject?.path) {
const activePath = normalizePath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const match = projects
.filter((project) => {
const projectPath = normalizePath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
})
.sort((left, right) => normalizePath(right.path).length - normalizePath(left.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false);
@@ -741,7 +711,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
const [isSavingPlan, setIsSavingPlan] = React.useState(false);
@@ -771,10 +741,13 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
return sessions.find((session) => session.id === currentSessionId) ?? null;
}, [currentSessionId, sessions]);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const currentProjectRef = React.useMemo(() => {
const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return resolveProjectRefForDirectory(directory, projects, activeProjectId);
}, [activeProjectId, currentSession?.directory, projects]);
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]);
const hasTools = toolParts.length > 0;
@@ -10,6 +10,8 @@ import { cn } from '@/lib/utils';
import { copyTextToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui';
import { getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { summarizeText } from '@/lib/voice/summarize';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -29,41 +31,6 @@ interface SelectionPayload {
rect: DOMRect;
}
const normalizeProjectPath = (value: string): string => {
const replaced = value.replace(/\\/g, '/').replace(/\/+$/g, '');
return replaced || value;
};
const resolveProjectRefForDirectory = (
directory: string,
projects: Array<{ id: string; path: string }>,
activeProjectId: string | null,
): { id: string; path: string } | null => {
const normalized = normalizeProjectPath(directory.trim());
if (!normalized) {
return null;
}
const activeProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
if (activeProject?.path) {
const activePath = normalizeProjectPath(activeProject.path);
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
return { id: activeProject.id, path: activeProject.path };
}
}
const match = projects
.filter((project) => {
const projectPath = normalizeProjectPath(project.path);
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
})
.sort((left, right) => normalizeProjectPath(right.path).length - normalizeProjectPath(left.path).length)[0];
return match ? { id: match.id, path: match.path } : null;
};
const appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '');
@@ -255,7 +222,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const effectiveDirectory = useEffectiveDirectory();
const sessions = useSessions();
React.useEffect(() => {
@@ -521,9 +489,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}, [currentSessionId, sessions]);
const currentProjectRef = React.useMemo(() => {
const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return resolveProjectRefForDirectory(directory, projects, activeProjectId);
}, [activeProjectId, currentSession?.directory, projects]);
const directory = effectiveDirectory
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory);
return resolved ? { id: resolved.id, path: resolved.path } : null;
}, [availableWorktreesByProject, currentSession?.directory, effectiveDirectory, projects]);
const handleAddToNotes = React.useCallback(async () => {
if (!selectedText || !currentProjectRef) {