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) {
+69
View File
@@ -0,0 +1,69 @@
import type { ProjectEntry } from "@/lib/api/types";
import type { WorktreeMetadata } from "@/types/worktree";
export const normalizeProjectPath = (value?: string | null): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const replaced = trimmed.replace(/\\/g, "/");
if (replaced === "/") return "/";
return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
};
export const resolveProjectForDirectory = (
projects: ProjectEntry[],
directory: string | null,
): ProjectEntry | null => {
const nd = normalizeProjectPath(directory);
if (!nd) return null;
let best: ProjectEntry | null = null;
for (const p of projects) {
const pp = normalizeProjectPath(p.path);
if (!pp) continue;
if (nd !== pp && !nd.startsWith(`${pp}/`)) continue;
if (!best || pp.length > (normalizeProjectPath(best.path)?.length ?? 0)) best = p;
}
return best;
};
export const resolveProjectFromWorktreeDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null => {
const nd = normalizeProjectPath(directory);
if (!nd) return null;
let matchedWorktree: WorktreeMetadata | null = null;
let matchedProjectPath: string | null = null;
let bestLen = -1;
for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) {
for (const wt of worktrees) {
const wp = normalizeProjectPath(wt.path);
if (!wp) continue;
if (nd !== wp && !nd.startsWith(`${wp}/`)) continue;
if (wp.length > bestLen) {
bestLen = wp.length;
matchedWorktree = wt;
matchedProjectPath = normalizeProjectPath(projectPath);
}
}
}
if (!matchedWorktree) return null;
const candidates = [normalizeProjectPath(matchedWorktree.projectDirectory), matchedProjectPath]
.filter((v): v is string => Boolean(v));
for (const c of candidates) {
const exact = projects.find((p) => normalizeProjectPath(p.path) === c) ?? null;
if (exact) return exact;
const nested = resolveProjectForDirectory(projects, c);
if (nested) return nested;
}
return null;
};
export const resolveProjectForSessionDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null =>
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ??
resolveProjectForDirectory(projects, directory);
+2 -53
View File
@@ -28,6 +28,7 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
import type { ProjectEntry } from "@/lib/api/types"
import {
getSyncSessions,
@@ -292,59 +293,7 @@ const persistDraftTarget = (target: PersistedDraftTarget): void => {
} catch { /* ignored */ }
}
const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => {
const nd = normalizePath(directory)
if (!nd) return null
let best: ProjectEntry | null = null
for (const p of projects) {
const pp = normalizePath(p.path)
if (!pp) continue
if (nd !== pp && !nd.startsWith(`${pp}/`)) continue
if (!best || pp.length > (normalizePath(best.path)?.length ?? 0)) best = p
}
return best
}
const resolveProjectFromWorktreeDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null => {
const nd = normalizePath(directory)
if (!nd) return null
let matchedWorktree: WorktreeMetadata | null = null
let matchedProjectPath: string | null = null
let bestLen = -1
for (const [projectPath, worktrees] of availableWorktreesByProject.entries()) {
for (const wt of worktrees) {
const wp = normalizePath(wt.path)
if (!wp) continue
if (nd !== wp && !nd.startsWith(`${wp}/`)) continue
if (wp.length > bestLen) {
bestLen = wp.length
matchedWorktree = wt
matchedProjectPath = normalizePath(projectPath)
}
}
}
if (!matchedWorktree) return null
const candidates = [normalizePath(matchedWorktree.projectDirectory), matchedProjectPath].filter((v): v is string => Boolean(v))
for (const c of candidates) {
const exact = projects.find((p) => normalizePath(p.path) === c) ?? null
if (exact) return exact
const nested = resolveProjectForDirectory(projects, c)
if (nested) return nested
}
return null
}
const resolveDraftProjectForDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null =>
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ??
resolveProjectForDirectory(projects, directory)
const resolveDraftProjectForDirectory = resolveProjectForSessionDirectory
const getAttachmentForSession = (sessionId: string | null | undefined): SessionWorktreeAttachment | undefined => {
if (!sessionId) return undefined