diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 5f02c8bf..dfbcc693 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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> = ({ 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> = ({ 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; diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index ce2df569..03c44f95 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -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 = ({ 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 = ({ 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) { diff --git a/packages/ui/src/lib/projectResolution.ts b/packages/ui/src/lib/projectResolution.ts new file mode 100644 index 00000000..21c48255 --- /dev/null +++ b/packages/ui/src/lib/projectResolution.ts @@ -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, + 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, + directory: string | null, +): ProjectEntry | null => + resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory) ?? + resolveProjectForDirectory(projects, directory); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 6dedde84..84b5b4a3 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -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, - 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, - 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