diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index b8235763..383404ec 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -407,7 +407,7 @@ export const SessionSidebar: React.FC = ({ return () => { cancelled = true; }; - }, [currentDirectory, syncSessionStructureSignature]); + }, [currentDirectory, syncSessionStructureSignature, projects]); React.useEffect(() => { let refreshTimeout: ReturnType | null = null; @@ -922,7 +922,7 @@ export const SessionSidebar: React.FC = ({ normalizedSessionSearchQuery, filterSessionNodesForSearch, buildGroupSearchText, - getFoldersForScope, + foldersMap, }); const searchEmptyState = ( @@ -1355,7 +1355,6 @@ export const SessionSidebar: React.FC = ({ expandedSessionGroups={expandedSessionGroups} collapsedGroups={collapsedGroups} hideDirectoryControls={hideDirectoryControls} - getFoldersForScope={getFoldersForScope} collapsedFolderIds={collapsedFolderIds} toggleFolderCollapse={toggleFolderCollapse} renameFolder={renameFolder} @@ -1393,7 +1392,6 @@ export const SessionSidebar: React.FC = ({ expandedSessionGroups, collapsedGroups, hideDirectoryControls, - getFoldersForScope, collapsedFolderIds, toggleFolderCollapse, renameFolder, diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index 3c2acc47..6be997c5 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -19,6 +19,7 @@ import type { SortableDragHandleProps } from './sortableItems'; import type { GroupSearchData, SessionGroup, SessionNode } from './types'; import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils'; import type { SessionFolder } from '@/stores/useSessionFoldersStore'; +import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; @@ -42,7 +43,6 @@ type Props = { expandedSessionGroups: Set; collapsedGroups: Set; hideDirectoryControls: boolean; - getFoldersForScope: (scopeKey: string) => SessionFolder[]; collapsedFolderIds: Set; toggleFolderCollapse: (folderId: string) => void; renameFolder: (scopeKey: string, folderId: string, name: string) => void; @@ -109,7 +109,6 @@ export function SessionGroupSection(props: Props): React.ReactNode { expandedSessionGroups, collapsedGroups, hideDirectoryControls, - getFoldersForScope, collapsedFolderIds, toggleFolderCollapse, renameFolder, @@ -153,6 +152,7 @@ export function SessionGroupSection(props: Props): React.ReactNode { const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const displayMode = useSessionDisplayStore((state) => state.displayMode); + const foldersMap = useSessionFoldersStore((state) => state.foldersMap); const isMinimalMode = displayMode === 'minimal'; const isExpanded = expandedSessionGroups.has(groupKey); const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey); @@ -166,8 +166,8 @@ export function SessionGroupSection(props: Props): React.ReactNode { ); const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); const scopeFolders = React.useMemo( - () => folderScopeKey ? getFoldersForScope(folderScopeKey) : [], - [folderScopeKey, getFoldersForScope] + () => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [], + [folderScopeKey, foldersMap] ); const nodeBySessionId = React.useMemo(() => { diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index d0984cdd..efa707e5 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -208,6 +208,7 @@ const areEqual = (prev: Props, next: Props): boolean => { if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false; if (prev.mobileVariant !== next.mobileVariant) return false; if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false; + if (prev.renamingFolderId !== next.renamingFolderId) return false; return true; }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts index 6a2aca34..1869ba0d 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -3,6 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionGroup, SessionNode, GroupSearchData } from '../types'; import { dedupeSessionsById, normalizePath } from '../utils'; import type { WorktreeMetadata } from '@/types/worktree'; +import type { SessionFoldersMap } from '@/stores/useSessionFoldersStore'; type ProjectItem = { id: string; @@ -39,7 +40,7 @@ type Args = { normalizedSessionSearchQuery: string; filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[]; buildGroupSearchText: (group: SessionGroup) => string; - getFoldersForScope: (scopeKey: string) => Array<{ name: string }>; + foldersMap: SessionFoldersMap; }; export const useSessionSidebarSections = (args: Args) => { @@ -56,7 +57,7 @@ export const useSessionSidebarSections = (args: Args) => { normalizedSessionSearchQuery, filterSessionNodesForSearch, buildGroupSearchText, - getFoldersForScope, + foldersMap, } = args; const projectSections = React.useMemo(() => { @@ -107,9 +108,8 @@ export const useSessionSidebarSections = (args: Args) => { const matchedSessionCount = countNodes(filteredNodes); const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery); const scopeKey = normalizePath(group.directory ?? null); - const folderNameMatchCount = scopeKey - ? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length - : 0; + const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : []; + const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length; result.set(group, { filteredNodes, @@ -128,7 +128,7 @@ export const useSessionSidebarSections = (args: Args) => { filterSessionNodesForSearch, normalizedSessionSearchQuery, buildGroupSearchText, - getFoldersForScope, + foldersMap, ]); const searchableProjectSections = React.useMemo(() => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index e33126e1..50ee1bec 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -13,6 +13,7 @@ import type { CreateGitWorktreePayload, GitWorktreeValidationResult, } from '@/lib/api/types'; +import { useSessionUIStore } from '@/sync/session-ui-store'; type WorktreeListEntry = { path?: string; @@ -316,6 +317,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr _worktreeListCache.delete(projectDirectory); + // Update sidebar store so new worktree appears immediately + const sidebarProjectKey = projectDirectory; + const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; + const updatedByProject = new Map(currentByProject); + const existing = updatedByProject.get(sidebarProjectKey) ?? []; + updatedByProject.set(sidebarProjectKey, [...existing, metadata]); + useSessionUIStore.setState({ + availableWorktreesByProject: updatedByProject, + availableWorktrees: [...useSessionUIStore.getState().availableWorktrees, metadata], + }); + return metadata; } @@ -330,7 +342,7 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt deleteLocalBranch?: boolean; remoteName?: string; }): Promise { - const projectDirectory = project.path; + const projectDirectory = normalizePath(project.path); const deleteRemote = Boolean(options?.deleteRemoteBranch); const deleteLocalBranch = options?.deleteLocalBranch === true; @@ -347,6 +359,34 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt _worktreeListCache.delete(normalizePath(project.path)); + // Update sidebar store so removed worktree disappears immediately + const normalizedWorktreePath = normalizePath(worktree.path); + const sidebarProjectKey = projectDirectory; + const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; + const updatedByProject = new Map(currentByProject); + const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? []; + updatedByProject.set( + sidebarProjectKey, + projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath), + ); + + // Clean up worktreeMetadata for sessions in the removed worktree + const currentMetadata = useSessionUIStore.getState().worktreeMetadata; + const updatedMetadata = new Map(currentMetadata); + for (const [sid, meta] of currentMetadata.entries()) { + if (meta && normalizePath(meta.path) === normalizedWorktreePath) { + updatedMetadata.delete(sid); + } + } + + useSessionUIStore.setState({ + availableWorktreesByProject: updatedByProject, + availableWorktrees: useSessionUIStore.getState().availableWorktrees.filter( + (w) => normalizePath(w.path) !== normalizedWorktreePath, + ), + worktreeMetadata: updatedMetadata, + }); + const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim(); if (deleteRemote && branchName) { await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined); diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 5d30153e..5e1f8116 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -9,6 +9,7 @@ import { getSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { PROJECT_COLORS } from '@/lib/projectMeta'; +import { useSessionUIStore } from '@/sync/session-ui-store'; /** Pick a color key that's least used among existing projects */ const pickAutoColor = (projects: ProjectEntry[]): string => { @@ -399,6 +400,7 @@ export const useProjectsStore = create()( return; } const current = get(); + const project = current.projects.find((p) => p.id === id); const nextProjects = current.projects.filter((project) => project.id !== id); let nextActiveId = current.activeProjectId; @@ -409,6 +411,16 @@ export const useProjectsStore = create()( set({ projects: nextProjects, activeProjectId: nextActiveId }); persistProjects(nextProjects, nextActiveId); + // Clean up worktree entries for the removed project + if (project) { + const normalizedPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '') || '/'; + useSessionUIStore.setState((s) => { + const next = new Map(s.availableWorktreesByProject); + next.delete(normalizedPath); + return { availableWorktreesByProject: next }; + }); + } + if (nextActiveId) { const nextActive = nextProjects.find((project) => project.id === nextActiveId); if (nextActive) { diff --git a/packages/ui/src/stores/useSessionFoldersStore.ts b/packages/ui/src/stores/useSessionFoldersStore.ts index 0c09c2b6..504e98b9 100644 --- a/packages/ui/src/stores/useSessionFoldersStore.ts +++ b/packages/ui/src/stores/useSessionFoldersStore.ts @@ -14,7 +14,7 @@ export interface SessionFolder { parentId?: string | null; } -type SessionFoldersMap = Record; +export type SessionFoldersMap = Record; interface SessionFoldersState { foldersMap: SessionFoldersMap; diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 3019f585..4de5df82 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -213,7 +213,26 @@ function optimisticRemoveSession(sessionId: string, directory?: string): Session export async function deleteSession(sessionId: string, _options?: Record): Promise { const sessionDirectory = getSessionDirectory(sessionId) // Remove from UI immediately, rollback on error - const snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + let snapshot = optimisticRemoveSession(sessionId, sessionDirectory) + let removedFromDir: string | null = snapshot ? (sessionDirectory ?? null) : null + + // If the session wasn't in the resolved directory (e.g. archived session + // whose original child store was disposed), search all child stores. + if (!snapshot && _childStores) { + for (const [dir, store] of _childStores.children.entries()) { + const current = store.getState() + const sessions = [...current.session] + const result = Binary.search(sessions, sessionId, (s) => s.id) + if (result.found) { + snapshot = current.session + sessions.splice(result.index, 1) + store.setState({ session: sessions }) + removedFromDir = dir + break + } + } + } + const ui = useSessionUIStore.getState() if (ui.currentSessionId === sessionId) { ui.setCurrentSession(null) @@ -224,7 +243,13 @@ export async function deleteSession(sessionId: string, _options?: Record