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:
@@ -38,6 +38,8 @@ import { StaticToolRow } from './parts/ProgressiveGroup';
|
|||||||
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
import { isExpandableTool, isStandaloneTool } from './parts/toolRenderUtils';
|
||||||
import TurnActivity from '../components/TurnActivity';
|
import TurnActivity from '../components/TurnActivity';
|
||||||
import { createProjectPlanFile } from '@/lib/openchamberConfig';
|
import { createProjectPlanFile } from '@/lib/openchamberConfig';
|
||||||
|
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||||
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { useSessions } from '@/sync/sync-context';
|
import { useSessions } from '@/sync/sync-context';
|
||||||
|
|
||||||
type SubtaskPartLike = Part & {
|
type SubtaskPartLike = Part & {
|
||||||
@@ -79,38 +81,6 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
|
|||||||
return `${providerID}/${modelID}`;
|
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 UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
@@ -741,7 +711,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
const openMultiRunLauncherWithPrompt = useUIStore((state) => state.openMultiRunLauncherWithPrompt);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
|
const [isPlanDialogOpen, setIsPlanDialogOpen] = React.useState(false);
|
||||||
const [isSavingPlan, setIsSavingPlan] = 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;
|
return sessions.find((session) => session.id === currentSessionId) ?? null;
|
||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
|
|
||||||
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const currentProjectRef = React.useMemo(() => {
|
const currentProjectRef = React.useMemo(() => {
|
||||||
const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
|
const directory = effectiveDirectory
|
||||||
return resolveProjectRefForDirectory(directory, projects, activeProjectId);
|
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
|
||||||
}, [activeProjectId, currentSession?.directory, projects]);
|
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;
|
const hasTools = toolParts.length > 0;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
import { getProjectNotesAndTodos, saveProjectNotesAndTodos } from '@/lib/openchamberConfig';
|
||||||
|
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||||
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { summarizeText } from '@/lib/voice/summarize';
|
import { summarizeText } from '@/lib/voice/summarize';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
|
||||||
@@ -29,41 +31,6 @@ interface SelectionPayload {
|
|||||||
rect: DOMRect;
|
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 appendDistilledInsightToNotes = (existingNotes: string, insight: string): string => {
|
||||||
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '');
|
const trimmedInsight = insight.trim().replace(/^[-*+]\s+/, '');
|
||||||
@@ -255,7 +222,8 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -521,9 +489,11 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
}, [currentSessionId, sessions]);
|
}, [currentSessionId, sessions]);
|
||||||
|
|
||||||
const currentProjectRef = React.useMemo(() => {
|
const currentProjectRef = React.useMemo(() => {
|
||||||
const directory = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
|
const directory = effectiveDirectory
|
||||||
return resolveProjectRefForDirectory(directory, projects, activeProjectId);
|
?? (typeof currentSession?.directory === 'string' ? currentSession.directory : '');
|
||||||
}, [activeProjectId, currentSession?.directory, projects]);
|
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 () => {
|
const handleAddToNotes = React.useCallback(async () => {
|
||||||
if (!selectedText || !currentProjectRef) {
|
if (!selectedText || !currentProjectRef) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -28,6 +28,7 @@ import { flattenAssistantTextParts } from "@/lib/messages/messageText"
|
|||||||
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"
|
import { EXECUTION_FORK_META_TEXT } from "@/lib/messages/executionMeta"
|
||||||
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"
|
||||||
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree"
|
||||||
|
import { resolveProjectForSessionDirectory } from "@/lib/projectResolution"
|
||||||
import type { ProjectEntry } from "@/lib/api/types"
|
import type { ProjectEntry } from "@/lib/api/types"
|
||||||
import {
|
import {
|
||||||
getSyncSessions,
|
getSyncSessions,
|
||||||
@@ -292,59 +293,7 @@ const persistDraftTarget = (target: PersistedDraftTarget): void => {
|
|||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveProjectForDirectory = (projects: ProjectEntry[], directory: string | null): ProjectEntry | null => {
|
const resolveDraftProjectForDirectory = resolveProjectForSessionDirectory
|
||||||
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 getAttachmentForSession = (sessionId: string | null | undefined): SessionWorktreeAttachment | undefined => {
|
const getAttachmentForSession = (sessionId: string | null | undefined): SessionWorktreeAttachment | undefined => {
|
||||||
if (!sessionId) return undefined
|
if (!sessionId) return undefined
|
||||||
|
|||||||
Reference in New Issue
Block a user