Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions electron/src/ipc/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import { getDataDir } from "../lib/data-dir";
import { log } from "../lib/logger";
import { captureEvent } from "../lib/posthog";

interface ChatFolder {
id: string;
name: string;
projectId: string;
createdAt: number;
order: number;
}

interface Project {
id: string;
name: string;
Expand All @@ -14,6 +22,7 @@ interface Project {
spaceId?: string;
icon?: string;
iconType?: "emoji" | "lucide";
folders?: ChatFolder[];
}

function getProjectsFilePath(): string {
Expand Down Expand Up @@ -189,4 +198,96 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
return { error: (err as Error).message };
}
});

ipcMain.handle("projects:create-folder", (_event, projectId: string, name: string) => {
try {
const projects = readProjects();
const project = projects.find((p) => p.id === projectId);
if (!project) return { error: "Project not found" };

const folder: ChatFolder = {
id: crypto.randomUUID(),
name,
projectId,
createdAt: Date.now(),
order: (project.folders?.length ?? 0),
};

const updatedProjects = projects.map((p) =>
p.id === projectId
? { ...p, folders: [...(p.folders ?? []), folder] }
: p,
);
writeProjects(updatedProjects);
void captureEvent("folder_created");
return { folder };
} catch (err) {
log("PROJECTS:CREATE_FOLDER_ERR", (err as Error).message);
return { error: (err as Error).message };
}
});

ipcMain.handle("projects:rename-folder", (_event, projectId: string, folderId: string, name: string) => {
try {
const projects = readProjects();
const updatedProjects = projects.map((p) => {
if (p.id !== projectId) return p;
const updatedFolders = (p.folders ?? []).map((f) =>
f.id === folderId ? { ...f, name } : f,
);
return { ...p, folders: updatedFolders };
});
writeProjects(updatedProjects);
return { ok: true };
} catch (err) {
log("PROJECTS:RENAME_FOLDER_ERR", (err as Error).message);
return { error: (err as Error).message };
}
});

ipcMain.handle("projects:delete-folder", (_event, projectId: string, folderId: string) => {
try {
const projects = readProjects();
const updatedProjects = projects.map((p) => {
if (p.id !== projectId) return p;
const updatedFolders = (p.folders ?? []).filter((f) => f.id !== folderId);
return { ...p, folders: updatedFolders };
Comment on lines +253 to +254
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear session folder assignments when deleting a folder

This handler only removes the folder entry from the project and never updates sessions that still reference that folderId. Those sessions are then excluded from the non-foldered list (src/components/sidebar/ProjectSection.tsx groups by session.folderId) and have no folder section to render under, so chats inside a deleted folder disappear from the sidebar and become effectively inaccessible. Deleting a folder should also unset/migrate folderId for affected sessions (including metadata sidecars).

Useful? React with 👍 / 👎.

});
writeProjects(updatedProjects);
return { ok: true };
} catch (err) {
log("PROJECTS:DELETE_FOLDER_ERR", (err as Error).message);
return { error: (err as Error).message };
}
});

ipcMain.handle("projects:reorder-folder", (_event, projectId: string, folderId: string, targetFolderId: string) => {
try {
const projects = readProjects();
const project = projects.find((p) => p.id === projectId);
if (!project || !project.folders) return { error: "Project or folders not found" };

const folders = [...project.folders];
const fromIdx = folders.findIndex((f) => f.id === folderId);
const toIdx = folders.findIndex((f) => f.id === targetFolderId);
if (fromIdx === -1 || toIdx === -1) return { error: "Folder not found" };

const [moved] = folders.splice(fromIdx, 1);
folders.splice(toIdx, 0, moved);

// Update order field
folders.forEach((f, i) => {
f.order = i;
});

const updatedProjects = projects.map((p) =>
p.id === projectId ? { ...p, folders } : p,
);
writeProjects(updatedProjects);
return { ok: true };
} catch (err) {
log("PROJECTS:REORDER_FOLDER_ERR", (err as Error).message);
return { error: (err as Error).message };
}
});
}
36 changes: 36 additions & 0 deletions electron/src/ipc/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,42 @@ export function register(): void {
}
});

ipcMain.handle("sessions:move-to-folder", async (_event, projectId: string, sessionId: string, folderId: string | null) => {
try {
const filePath = getSessionFilePath(projectId, sessionId);
const data = JSON.parse(await fs.promises.readFile(filePath, "utf-8"));

// Update folderId (or remove it if null)
if (folderId === null) {
delete data.folderId;
} else {
data.folderId = folderId;
}

// Save updated session
await fs.promises.writeFile(filePath, JSON.stringify(data), "utf-8");

// Update metadata sidecar if it exists
const metaPath = getMetaFilePath(projectId, sessionId);
try {
const metaData = JSON.parse(await fs.promises.readFile(metaPath, "utf-8"));
if (folderId === null) {
delete metaData.folderId;
} else {
metaData.folderId = folderId;
}
await fs.promises.writeFile(metaPath, JSON.stringify(metaData), "utf-8");
} catch {
// Metadata sidecar doesn't exist, that's okay
}

return { ok: true };
} catch (err) {
const message = reportError("SESSIONS:MOVE_TO_FOLDER_ERR", err, { projectId, sessionId, folderId });
return { error: message };
}
});

ipcMain.handle("sessions:search", async (_event, { projectIds, query }: { projectIds: string[]; query: string }): Promise<SearchResult> => {
try {
const lowerQuery = query.toLowerCase();
Expand Down
5 changes: 5 additions & 0 deletions electron/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,12 +104,17 @@ contextBridge.exposeInMainWorld("claude", {
updateSpace: (projectId: string, spaceId: string) => ipcRenderer.invoke("projects:update-space", projectId, spaceId),
updateIcon: (projectId: string, icon: string | null, iconType: "emoji" | "lucide" | null) => ipcRenderer.invoke("projects:update-icon", projectId, icon, iconType),
reorder: (projectId: string, targetProjectId: string) => ipcRenderer.invoke("projects:reorder", projectId, targetProjectId),
createFolder: (projectId: string, name: string) => ipcRenderer.invoke("projects:create-folder", projectId, name),
renameFolder: (projectId: string, folderId: string, name: string) => ipcRenderer.invoke("projects:rename-folder", projectId, folderId, name),
deleteFolder: (projectId: string, folderId: string) => ipcRenderer.invoke("projects:delete-folder", projectId, folderId),
reorderFolder: (projectId: string, folderId: string, targetFolderId: string) => ipcRenderer.invoke("projects:reorder-folder", projectId, folderId, targetFolderId),
},
sessions: {
save: (data: unknown) => ipcRenderer.invoke("sessions:save", data),
load: (projectId: string, sessionId: string) => ipcRenderer.invoke("sessions:load", projectId, sessionId),
list: (projectId: string) => ipcRenderer.invoke("sessions:list", projectId),
delete: (projectId: string, sessionId: string) => ipcRenderer.invoke("sessions:delete", projectId, sessionId),
moveToFolder: (projectId: string, sessionId: string, folderId: string | null) => ipcRenderer.invoke("sessions:move-to-folder", projectId, sessionId, folderId),
search: (projectIds: string[], query: string) => ipcRenderer.invoke("sessions:search", { projectIds, query }),
},
spaces: {
Expand Down
26 changes: 26 additions & 0 deletions src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,27 @@ export function AppLayout() {
setJiraBoardProjectForSpace(spaceId, currentProjectId === projectId ? null : projectId);
}, [jiraBoardBySpace, projectManager.projects, setJiraBoardProjectForSpace]);

const handleMoveSessionToFolder = useCallback(
async (sessionId: string, folderId: string | null) => {
// Find the session in our local state
const session = manager.sessions.find((s) => s.id === sessionId);
if (!session) return;

// Update the session's folderId via IPC
await window.claude.sessions.moveToFolder(session.projectId, sessionId, folderId);

// Update the session state locally
manager.setSessions((prev) =>
prev.map((s) =>
s.id === sessionId
? { ...s, folderId: folderId ?? undefined }
: s,
),
);
},
[manager],
);

// Handler for creating task from Jira issue
const handleCreateTaskFromJiraIssue = useCallback(
(projectId: string, issue: JiraIssue) => {
Expand Down Expand Up @@ -389,6 +410,7 @@ Link: ${issue.url}`;
onSelectSession={handleSidebarSelectSession}
onDeleteSession={manager.deleteSession}
onRenameSession={manager.renameSession}
onMoveSessionToFolder={handleMoveSessionToFolder}
onCreateProject={handleCreateProject}
onDeleteProject={projectManager.deleteProject}
onRenameProject={projectManager.renameProject}
Expand All @@ -398,6 +420,10 @@ Link: ${issue.url}`;
onNavigateToMessage={handleNavigateToMessage}
onMoveProjectToSpace={handleMoveProjectToSpace}
onReorderProject={projectManager.reorderProject}
onCreateFolder={projectManager.createFolder}
onRenameFolder={projectManager.renameFolder}
onDeleteFolder={projectManager.deleteFolder}
onReorderFolder={projectManager.reorderFolder}
spaces={spaceManager.spaces}
activeSpaceId={spaceManager.activeSpaceId}
onSelectSpace={spaceManager.setActiveSpaceId}
Expand Down
15 changes: 15 additions & 0 deletions src/components/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface AppSidebarProps {
onSelectSession: (id: string) => void;
onDeleteSession: (id: string) => void;
onRenameSession: (id: string, title: string) => void;
onMoveSessionToFolder: (sessionId: string, folderId: string | null) => void;
onCreateProject: () => void;
onDeleteProject: (id: string) => void;
onRenameProject: (id: string, name: string) => void;
Expand All @@ -32,6 +33,10 @@ interface AppSidebarProps {
onNavigateToMessage: (sessionId: string, messageId: string) => void;
onMoveProjectToSpace: (projectId: string, spaceId: string) => void;
onReorderProject: (projectId: string, targetProjectId: string) => void;
onCreateFolder: (projectId: string, name: string) => void;
onRenameFolder: (projectId: string, folderId: string, name: string) => void;
onDeleteFolder: (projectId: string, folderId: string) => void;
onReorderFolder: (projectId: string, folderId: string, targetFolderId: string) => void;
spaces: Space[];
activeSpaceId: string;
onSelectSpace: (id: string) => void;
Expand All @@ -55,6 +60,7 @@ export const AppSidebar = memo(function AppSidebar({
onSelectSession,
onDeleteSession,
onRenameSession,
onMoveSessionToFolder,
onCreateProject,
onDeleteProject,
onRenameProject,
Expand All @@ -64,6 +70,10 @@ export const AppSidebar = memo(function AppSidebar({
onNavigateToMessage,
onMoveProjectToSpace,
onReorderProject,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onReorderFolder,
spaces,
activeSpaceId,
onSelectSpace,
Expand Down Expand Up @@ -234,6 +244,7 @@ export const AppSidebar = memo(function AppSidebar({
onSelectSession={onSelectSession}
onDeleteSession={onDeleteSession}
onRenameSession={onRenameSession}
onMoveSessionToFolder={onMoveSessionToFolder}
onDeleteProject={() => onDeleteProject(project.id)}
onRenameProject={(name) => onRenameProject(project.id, name)}
onUpdateIcon={(icon, iconType) =>
Expand All @@ -249,6 +260,10 @@ export const AppSidebar = memo(function AppSidebar({
onReorderProject={(targetId) =>
onReorderProject(project.id, targetId)
}
onCreateFolder={(name) => onCreateFolder(project.id, name)}
onRenameFolder={(folderId, name) => onRenameFolder(project.id, folderId, name)}
onDeleteFolder={(folderId) => onDeleteFolder(project.id, folderId)}
onReorderFolder={(folderId, targetId) => onReorderFolder(project.id, folderId, targetId)}
defaultChatLimit={defaultChatLimit}
agents={agents}
/>
Expand Down
Loading