diff --git a/package-lock.json b/package-lock.json
index 3e552cb..85bf338 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
+ "tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -18950,6 +18951,15 @@
"node": "*"
}
},
+ "node_modules/tw-animate-css": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
+ "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Wombosvideo"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index d0f612c..3701c25 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
+ "tw-animate-css": "^1.4.0",
"zod": "^4.3.6"
}
}
diff --git a/src/main/router/index.ts b/src/main/router/index.ts
index 2be5908..100e497 100644
--- a/src/main/router/index.ts
+++ b/src/main/router/index.ts
@@ -166,6 +166,13 @@ const projectsRouter = router({
db.delete(projects).where(eq(projects.id, input.id)).run();
return { success: true as const };
}),
+
+ archiveByClient: publicProcedure
+ .input(z.object({ clientId: z.string(), status: z.enum(['active', 'archived']) }))
+ .mutation(({ input }) => {
+ getDb().update(projects).set({ status: input.status }).where(eq(projects.clientId, input.clientId)).run();
+ return { success: true as const };
+ }),
});
const tasksRouter = router({
diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx
index e28671a..80dae71 100644
--- a/src/renderer/components/layout/AppShell.tsx
+++ b/src/renderer/components/layout/AppShell.tsx
@@ -6,7 +6,7 @@ import {
ClipboardCheck,
FolderKanban,
PanelLeft,
- ChevronDown,
+ ChevronUp,
} from 'lucide-react';
import { trpc } from '@/lib/trpc';
import {
@@ -61,15 +61,15 @@ export function AppShell({ children }: AppShellProps) {
{children}
{/* Right-edge vertical 'keep scrolling for AI' affordance (non-interactive) */}
-
+
+
- keep scrolling for AI
+ keep scrolling up for AI
-
diff --git a/src/renderer/components/projects/ProjectSidebar.tsx b/src/renderer/components/projects/ProjectSidebar.tsx
index 7167a37..bfb8e7b 100644
--- a/src/renderer/components/projects/ProjectSidebar.tsx
+++ b/src/renderer/components/projects/ProjectSidebar.tsx
@@ -5,7 +5,7 @@ import {
ChevronRight,
ChevronDown,
Plus,
- MoreHorizontal,
+ Pencil,
Edit2,
Archive,
ArchiveRestore,
@@ -18,12 +18,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from '@/components/ui/context-menu';
import {
AlertDialog,
AlertDialogAction,
@@ -77,8 +77,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const [searchQuery, setSearchQuery] = useState('');
const [expanded, setExpanded] = useState
>(new Set());
const [deleteProjectId, setDeleteProjectId] = useState<{ id: string; name: string } | null>(null);
+ const [renameProject, setRenameProject] = useState<{ id: string; name: string } | null>(null);
+ const [renameProjectValue, setRenameProjectValue] = useState('');
const [editDialog, setEditDialog] = useState<{ id: string; name: string; clientId: string | null } | null>(null);
+
+ // Client action state
+ const [renameClient, setRenameClient] = useState<{ id: string; name: string } | null>(null);
+ const [renameClientValue, setRenameClientValue] = useState('');
+ const [deleteClient, setDeleteClient] = useState<{ id: string; name: string } | null>(null);
const [editClientValue, setEditClientValue] = useState(NO_CLIENT_KEY);
+ const [editSubClientValue, setEditSubClientValue] = useState(NO_CLIENT_KEY);
+ const [editCreatingClient, setEditCreatingClient] = useState(false);
+ const [editNewClientName, setEditNewClientName] = useState('');
+ const [editCreatingSubClient, setEditCreatingSubClient] = useState(false);
+ const [editNewSubClientName, setEditNewSubClientName] = useState('');
// New-project dialog state
const [newProjectOpen, setNewProjectOpen] = useState(false);
@@ -141,6 +153,25 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
},
});
+ const updateClientMutation = trpc.clients.update.useMutation({
+ onSuccess: () => {
+ setRenameClient(null);
+ void utils.clients.list.invalidate();
+ },
+ });
+
+ const archiveByClientMutation = trpc.projects.archiveByClient.useMutation({
+ onSuccess: () => { void utils.projects.list.invalidate(); },
+ });
+
+ const deleteClientMutation = trpc.clients.deleteWithCascade.useMutation({
+ onSuccess: () => {
+ setDeleteClient(null);
+ void utils.clients.list.invalidate();
+ void utils.projects.list.invalidate();
+ },
+ });
+
// Build a client lookup map
const clientMap = useMemo(() => {
const m = new Map();
@@ -156,6 +187,12 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
);
const groups = new Map();
+
+ // Ensure every client appears even with 0 projects
+ for (const c of clientList) {
+ groups.set(c.id, []);
+ }
+
for (const p of filtered) {
const key = p.clientId ?? NO_CLIENT_KEY;
const arr = groups.get(key);
@@ -163,7 +200,20 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
else groups.set(key, [p]);
}
return groups;
- }, [projectList, searchQuery]);
+ }, [projectList, clientList, searchQuery]);
+
+ // Count projects for a top-level client including its sub-clients
+ const totalProjectCount = useMemo(() => {
+ const counts = new Map();
+ for (const c of topLevelClients) {
+ let count = (grouped.get(c.id) ?? []).length;
+ for (const sc of subClientsByParent.get(c.id) ?? []) {
+ count += (grouped.get(sc.id) ?? []).length;
+ }
+ counts.set(c.id, count);
+ }
+ return counts;
+ }, [topLevelClients, subClientsByParent, grouped]);
// Auto-expand all groups when searching
const effectiveExpanded = useMemo(() => {
@@ -242,22 +292,76 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
function handleEditOpen(project: { id: string; name: string; clientId: string | null }) {
setEditDialog(project);
- setEditClientValue(project.clientId ?? NO_CLIENT_KEY);
+ setEditCreatingClient(false);
+ setEditNewClientName('');
+ setEditCreatingSubClient(false);
+ setEditNewSubClientName('');
+ if (!project.clientId) {
+ setEditClientValue(NO_CLIENT_KEY);
+ setEditSubClientValue(NO_CLIENT_KEY);
+ } else {
+ // Check if clientId is a sub-client
+ const client = clientList.find((c) => c.id === project.clientId);
+ if (client?.parentId) {
+ // It's a sub-client — set parent as client, this as sub-client
+ setEditClientValue(client.parentId);
+ setEditSubClientValue(client.id);
+ } else {
+ setEditClientValue(project.clientId);
+ setEditSubClientValue(NO_CLIENT_KEY);
+ }
+ }
}
- function handleEditSave() {
+ async function handleEditSave() {
if (!editDialog) return;
- const newClientId = editClientValue === NO_CLIENT_KEY ? null : editClientValue;
+
+ let resolvedClientId: string | null;
+
+ if (editCreatingClient && editNewClientName.trim()) {
+ // Create a new client
+ const result = await createClientMutation.mutateAsync({ name: editNewClientName.trim() });
+ let parentId = result.id;
+
+ if (editCreatingSubClient && editNewSubClientName.trim()) {
+ // Also create a sub-client under the new client
+ const subResult = await createClientMutation.mutateAsync({
+ name: editNewSubClientName.trim(),
+ parentId,
+ });
+ resolvedClientId = subResult.id;
+ } else {
+ resolvedClientId = parentId;
+ }
+ } else if (editCreatingSubClient && editNewSubClientName.trim() && editClientValue !== NO_CLIENT_KEY) {
+ // Create a new sub-client under the selected parent
+ const result = await createClientMutation.mutateAsync({
+ name: editNewSubClientName.trim(),
+ parentId: editClientValue,
+ });
+ resolvedClientId = result.id;
+ } else if (editSubClientValue !== NO_CLIENT_KEY) {
+ resolvedClientId = editSubClientValue;
+ } else if (editClientValue !== NO_CLIENT_KEY) {
+ resolvedClientId = editClientValue;
+ } else {
+ resolvedClientId = null;
+ }
+
updateMutation.mutate(
- { id: editDialog.id, clientId: newClientId },
+ { id: editDialog.id, clientId: resolvedClientId },
{ onSuccess: () => setEditDialog(null) },
);
}
- // Sort groups: client groups first (alphabetically), then "Internal / No Client" last
+ // Sort groups: only top-level clients + NO_CLIENT_KEY, sub-clients rendered nested
const sortedGroupKeys = useMemo(() => {
- const keys = [...grouped.keys()];
+ // Only include top-level clients and the NO_CLIENT_KEY group
+ const topLevelIds = new Set(topLevelClients.map((c) => c.id));
+ const keys = [...grouped.keys()].filter(
+ (k) => k === NO_CLIENT_KEY || topLevelIds.has(k),
+ );
return keys.sort((a, b) => {
if (a === NO_CLIENT_KEY) return 1;
if (b === NO_CLIENT_KEY) return -1;
@@ -265,7 +369,7 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
const nameB = clientMap.get(b) ?? '';
return nameA.localeCompare(nameB);
});
- }, [grouped, clientMap]);
+ }, [grouped, clientMap, topLevelClients]);
const totalProjects = projectList.length;
@@ -336,16 +440,16 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
- ) : sortedGroupKeys.length === 0 ? (
+ ) : sortedGroupKeys.length === 0 && clientList.length === 0 ? (
No projects match your search.
) : (
- sortedGroupKeys.map((groupKey) => {
+ <>
+ {/* Client groups */}
+ {sortedGroupKeys.filter((k) => k !== NO_CLIENT_KEY).map((groupKey) => {
const groupProjects = grouped.get(groupKey) ?? [];
- const groupName = groupKey === NO_CLIENT_KEY
- ? 'Internal / No Client'
- : clientMap.get(groupKey) ?? 'Unknown Client';
+ const groupName = clientMap.get(groupKey) ?? 'Unknown Client';
const isOpen = effectiveExpanded.has(groupKey);
return (
@@ -354,110 +458,415 @@ export function ProjectSidebar({ selectedProjectId, onSelectProject }: ProjectSi
open={isOpen}
onOpenChange={() => toggleExpanded(groupKey)}
>
- {/* Client group header */}
-
-
- {isOpen ? (
-
- ) : (
-
- )}
-
-
- {groupName}
-
-
- {groupProjects.length}
-
-
-
+
+
+
+
+ {isOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {groupName}
+
+
+ {totalProjectCount.get(groupKey) ?? groupProjects.length}
+
+
+
+
+
+ {
+ setRenameClient({ id: groupKey, name: groupName });
+ setRenameClientValue(groupName);
+ }}
+ >
+
+ Rename
+
+ {
+ const allArchived = groupProjects.every((p) => p.status === 'archived');
+ archiveByClientMutation.mutate({
+ clientId: groupKey,
+ status: allArchived ? 'active' : 'archived',
+ });
+ }}
+ >
+ {groupProjects.every((p) => p.status === 'archived') ? (
+ <>
+
+ Unarchive All
+ >
+ ) : (
+ <>
+
+ Archive All
+ >
+ )}
+
+
+ {
+ setDeleteClient({ id: groupKey, name: groupName });
+ }}
+ >
+
+ Delete
+
+
+
- {groupProjects.map((project) => (
- onSelectProject(project.id)}
- >
-
-
- {project.name}
-
+ {/* Sub-clients nested under this parent */}
+ {(subClientsByParent.get(groupKey) ?? []).map((subClient) => {
+ const subProjects = grouped.get(subClient.id) ?? [];
+ const subIsOpen = effectiveExpanded.has(subClient.id);
+ const subName = subClient.name;
- {/* Context menu */}
-
-
-
-
-
- {
- e.stopPropagation();
- handleEditOpen({
- id: project.id,
- name: project.name,
- clientId: project.clientId,
- });
- }}
- >
-
- Edit Client
-
- {
- e.stopPropagation();
- handleArchiveToggle(project.id, project.status);
- }}
- >
- {project.status === 'archived' ? (
- <>
-
- Unarchive
- >
- ) : (
- <>
-
- Archive
- >
- )}
-
-
- {
- e.stopPropagation();
- setDeleteProjectId({ id: project.id, name: project.name });
- }}
- >
-
- Delete
-
-
-
-
+ return (
+ toggleExpanded(subClient.id)}
+ >
+
+
+
+
+ {subIsOpen ? (
+
+ ) : (
+
+ )}
+
+
+ {subName}
+
+
+ {subProjects.length}
+
+
+
+
+
+ {
+ setRenameClient({ id: subClient.id, name: subName });
+ setRenameClientValue(subName);
+ }}
+ >
+
+ Rename
+
+ {
+ const allArchived = subProjects.every((p) => p.status === 'archived');
+ archiveByClientMutation.mutate({
+ clientId: subClient.id,
+ status: allArchived ? 'active' : 'archived',
+ });
+ }}
+ >
+ {subProjects.every((p) => p.status === 'archived') ? (
+ <>
+
+ Unarchive All
+ >
+ ) : (
+ <>
+
+ Archive All
+ >
+ )}
+
+
+ {
+ setDeleteClient({ id: subClient.id, name: subName });
+ }}
+ >
+
+ Delete
+
+
+
+
+
+ {subProjects.map((project) => (
+
+
+ onSelectProject(project.id)}
+ >
+
+
+ {project.name}
+
+
+
+
+ {
+ setRenameProject({ id: project.id, name: project.name });
+ setRenameProjectValue(project.name);
+ }}
+ >
+
+ Rename
+
+ {
+ handleEditOpen({
+ id: project.id,
+ name: project.name,
+ clientId: project.clientId,
+ });
+ }}
+ >
+
+ Edit Client
+
+ {
+ handleArchiveToggle(project.id, project.status);
+ }}
+ >
+ {project.status === 'archived' ? (
+ <>
+
+ Unarchive
+ >
+ ) : (
+ <>
+
+ Archive
+ >
+ )}
+
+
+ {
+ setDeleteProjectId({ id: project.id, name: project.name });
+ }}
+ >
+
+ Delete
+
+
+
+ ))}
+
+
+ );
+ })}
+
+ {/* Direct projects under this client */}
+ {groupProjects.map((project) => (
+
+
+ onSelectProject(project.id)}
+ >
+
+
+ {project.name}
+
+
+
+
+ {
+ setRenameProject({ id: project.id, name: project.name });
+ setRenameProjectValue(project.name);
+ }}
+ >
+
+ Rename
+
+ {
+ handleEditOpen({
+ id: project.id,
+ name: project.name,
+ clientId: project.clientId,
+ });
+ }}
+ >
+
+ Edit Client
+
+ {
+ handleArchiveToggle(project.id, project.status);
+ }}
+ >
+ {project.status === 'archived' ? (
+ <>
+
+ Unarchive
+ >
+ ) : (
+ <>
+
+ Archive
+ >
+ )}
+
+
+ {
+ setDeleteProjectId({ id: project.id, name: project.name });
+ }}
+ >
+
+ Delete
+
+
+
))}
);
- })
+ })}
+
+ {/* Unassigned projects (no client) — rendered flat at root level */}
+ {(grouped.get(NO_CLIENT_KEY) ?? []).map((project) => (
+
+
+ onSelectProject(project.id)}
+ >
+
+
+ {project.name}
+
+
+
+
+ {
+ setRenameProject({ id: project.id, name: project.name });
+ setRenameProjectValue(project.name);
+ }}
+ >
+
+ Rename
+
+ {
+ handleEditOpen({
+ id: project.id,
+ name: project.name,
+ clientId: project.clientId,
+ });
+ }}
+ >
+
+ Edit Client
+
+ {
+ handleArchiveToggle(project.id, project.status);
+ }}
+ >
+ {project.status === 'archived' ? (
+ <>
+
+ Unarchive
+ >
+ ) : (
+ <>
+
+ Archive
+ >
+ )}
+
+
+ {
+ setDeleteProjectId({ id: project.id, name: project.name });
+ }}
+ >
+
+ Delete
+
+
+
+ ))}
+ >
)}
+ {/* Rename project dialog */}
+
+
{/* Delete confirmation */}
+ {/* Delete client confirmation */}
+ {
+ if (!open && !deleteClientMutation.isPending) setDeleteClient(null);
+ }}
+ >
+
+
+
+ Delete “{deleteClient?.name}”?
+
+
+ This will delete the client and all its projects. Tasks assigned to those projects will become unassigned. This action cannot be undone.
+
+
+
+ Cancel
+ {
+ if (deleteClient) deleteClientMutation.mutate({ id: deleteClient.id });
+ }}
+ disabled={deleteClientMutation.isPending}
+ >
+ {deleteClientMutation.isPending ? 'Deleting\u2026' : 'Delete'}
+
+
+
+
+
+ {/* Rename client dialog */}
+
+
{/* Edit client dialog */}