Compare commits

...

3 Commits

Author SHA1 Message Date
0d98a36317 fix(client): reduce unnecessary renders and fetches on hard refresh
Some checks failed
CI / test-python (push) Successful in 4m54s
CI / test-typescript (push) Failing after 57s
CI / test-rust (push) Successful in 1m56s
- Contexts wait for connection before loading to avoid stale cached data
- MeetingsPage skips fetch when URL project ID is invalid
- Workspace isLoading starts as true for proper loading state
- Remove debug logging added during investigation
- Add PROJECTS_FETCH_LIMIT constant to timing.ts
2026-01-26 14:59:50 +00:00
01a8d02d60 fix(client): initialize project loading state to true to prevent race condition
Some checks failed
CI / test-python (push) Successful in 4m53s
CI / test-typescript (push) Failing after 1m8s
CI / test-rust (push) Successful in 1m56s
The Meetings page skips fetching when projectsLoading is false and
activeProject is null. Since isLoading started as false, the initial
render would skip the fetch before projects had a chance to load.

By initializing isLoading to true, the Meetings page waits for the
project context to finish loading before deciding whether to fetch.
2026-01-26 13:50:28 +00:00
f9db9cd8ca fix(client): close delete dialog on successful bulk deletion
Added onSuccess callback to useDeleteMeetings hook and use it in
Meetings.tsx to close dialog, clear selection, and exit selection mode.

Removed flaky useEffect that tried to detect deletion success by
checking if meetings still existed in the list.
2026-01-26 13:40:10 +00:00
5 changed files with 55 additions and 29 deletions

View File

@@ -5,9 +5,11 @@ import { IdentityDefaults } from '@/api';
import { extractErrorMessage } from '@/api';
import { getAPI } from '@/api/interface';
import type { CreateProjectRequest, Project, UpdateProjectRequest } from '@/api/types';
import { useConnectionState } from '@/contexts/connection-state';
import { ProjectContext, type ProjectContextValue } from '@/contexts/project-state';
import { projectStorageKey } from '@/contexts/storage';
import { useWorkspace } from '@/contexts/workspace-state';
import { PROJECTS_FETCH_LIMIT } from '@/lib/constants/timing';
import { errorLog } from '@/lib/observability/debug';
import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils';
@@ -54,9 +56,10 @@ function fallbackProject(workspaceId: string): Project {
export function ProjectProvider({ children }: { children: React.ReactNode }) {
const { currentWorkspace } = useWorkspace();
const { isConnected } = useConnectionState();
const [projects, setProjects] = useState<Project[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Use ref to avoid recreating loadProjects when currentWorkspace changes
@@ -75,7 +78,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
const response = await getAPI().listProjects({
workspace_id: workspace.id,
include_archived: true,
limit: 200,
limit: PROJECTS_FETCH_LIMIT,
offset: 0,
});
let preferredId = readStoredProjectId(workspace.id);
@@ -110,14 +113,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }) {
}
}, []);
// Reload projects when workspace ID changes (not on every workspace object reference change)
const workspaceId = currentWorkspace?.id;
const hasLoaded = useRef(false);
useEffect(() => {
if (!workspaceId) {
if (!workspaceId || !isConnected || hasLoaded.current) {
return;
}
hasLoaded.current = true;
void loadProjects();
}, [loadProjects, workspaceId]);
}, [loadProjects, workspaceId, isConnected]);
const switchProject = useCallback(
(projectId: string) => {

View File

@@ -5,13 +5,16 @@ import { IdentityDefaults } from '@/api';
import { getAPI } from '@/api/interface';
import { extractErrorMessage } from '@/api';
import type { GetCurrentUserResponse, Workspace } from '@/api/types';
import { useConnectionState } from '@/contexts/connection-state';
import { WorkspaceContext, type WorkspaceContextValue } from '@/contexts/workspace-state';
import { workspaceStorageKey } from '@/contexts/storage';
import { readStorageRaw, writeStorageRaw } from '@/lib/storage/utils';
const fallbackUser: GetCurrentUserResponse = {
user_id: IdentityDefaults.DEFAULT_USER_ID,
workspace_id: IdentityDefaults.DEFAULT_WORKSPACE_ID,
display_name: IdentityDefaults.DEFAULT_USER_NAME,
is_authenticated: false,
};
const fallbackWorkspace: Workspace = {
id: IdentityDefaults.DEFAULT_WORKSPACE_ID,
@@ -44,10 +47,11 @@ function resolveWorkspace(workspaces: Workspace[], preferredId: string | null):
}
export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
const { isConnected } = useConnectionState();
const [currentWorkspace, setCurrentWorkspace] = useState<Workspace | null>(null);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [currentUser, setCurrentUser] = useState<GetCurrentUserResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadContext = useCallback(async () => {
@@ -85,9 +89,17 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) {
}
}, []);
const hasLoaded = useRef(false);
useEffect(() => {
if (hasLoaded.current) {
return;
}
if (!isConnected) {
return;
}
hasLoaded.current = true;
void loadContext();
}, [loadContext]);
}, [loadContext, isConnected]);
// Use ref for workspaces to avoid stale closure in switchWorkspace
const workspacesRef = useRef(workspaces);

View File

@@ -97,7 +97,11 @@ interface DeleteMeetingsContext {
removedMeetings: Map<string, Meeting>;
}
export function useDeleteMeetings() {
interface UseDeleteMeetingsOptions {
onSuccess?: (result: DeleteMeetingsResult) => void;
}
export function useDeleteMeetings(options?: UseDeleteMeetingsOptions) {
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -147,6 +151,8 @@ export function useDeleteMeetings() {
description: `Skipped ${result.skippedIds.length} active meeting(s)`,
});
}
options?.onSuccess?.(result);
},
onError: (_error, _meetingIds, context) => {
// Restore removed meetings to cache on error

View File

@@ -123,6 +123,9 @@ export const HOME_TASKS_LIMIT = 5;
/** Default number of meetings to show on meetings list page */
export const MEETINGS_PAGE_LIMIT = 50;
/** Maximum number of projects to fetch in project context */
export const PROJECTS_FETCH_LIMIT = 200;
/** Default number of meetings to fetch for tasks page */
export const TASKS_PAGE_MEETINGS_LIMIT = 100;

View File

@@ -2,7 +2,7 @@
import { Calendar, Loader2, CheckSquare } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Navigate, useParams } from 'react-router-dom';
import { getAPI } from '@/api/interface';
import type { Meeting, MeetingState } from '@/api/types';
import type { ProjectScope } from '@/api/types/requests';
@@ -38,7 +38,7 @@ export default function MeetingsPage() {
});
const { guard } = useGuardedMutation();
const resolvedProjectId = projectId ?? activeProject?.id;
const selectedIdsRef = useRef(selectedProjectIds);
selectedIdsRef.current = selectedProjectIds;
const activeProjects = useMemo(
@@ -51,15 +51,25 @@ export default function MeetingsPage() {
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
// Bulk selection state
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<string>>(new Set());
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const { mutate: deleteMeetings, isLoading: isDeleting } = useDeleteMeetings();
const handleDeleteSuccess = useCallback(() => {
setShowBulkDeleteDialog(false);
setSelectedMeetingIds(new Set());
setIsSelectionMode(false);
}, []);
const { mutate: deleteMeetings, isLoading: isDeleting } = useDeleteMeetings({
onSuccess: handleDeleteSuccess,
});
const urlProjectValid = !projectId || projects.some((p) => p.id === projectId);
const shouldSkipFetch =
(projectScope === 'selected' && selectedProjectIds.length === 0) ||
(projectScope === 'active' && !resolvedProjectId && !projectsLoading);
(projectScope === 'active' && !resolvedProjectId && !projectsLoading) ||
(projectScope === 'active' && projectId && !projectsLoading && !urlProjectValid);
const fetchMeetings = useCallback(
async (offset: number, append: boolean) => {
@@ -77,11 +87,12 @@ export default function MeetingsPage() {
}
try {
const requestProjectId = projectScope === 'active' ? resolvedProjectId : undefined;
const response = await getAPI().listMeetings({
limit: MEETINGS_PAGE_LIMIT,
offset,
states: stateFilter === 'all' ? undefined : [stateFilter as MeetingState],
project_id: projectScope === 'active' ? resolvedProjectId : undefined,
project_id: requestProjectId,
project_ids: projectScope === 'selected' ? selectedIdsRef.current : undefined,
include_segments: true,
});
@@ -169,21 +180,6 @@ export default function MeetingsPage() {
deleteMeetings(Array.from(selectedMeetingIds));
}, [deleteMeetings, selectedMeetingIds]);
// Handle successful deletion
useEffect(() => {
if (!isDeleting && selectedMeetingIds.size > 0) {
// Check if deletion was successful by verifying meetings were removed
const deletedIds = Array.from(selectedMeetingIds);
const stillExists = meetings.some((m) => deletedIds.includes(m.id));
if (!stillExists) {
setSelectedMeetingIds(new Set());
setShowBulkDeleteDialog(false);
setIsSelectionMode(false);
fetchMeetings(0, false);
}
}
}, [isDeleting, selectedMeetingIds, meetings, fetchMeetings]);
const hasMore = meetings.length < totalCount;
const handleLoadMore = () => {
@@ -204,6 +200,11 @@ export default function MeetingsPage() {
}
};
const projectExistsInList = projectId && projects.some((p) => p.id === projectId);
if (!projectsLoading && projectId && !projectExistsInList && activeProject) {
return <Navigate to={`/projects/${activeProject.id}/meetings`} replace />;
}
return (
<div className="p-6 max-w-6xl mx-auto space-y-6">
<div className="flex items-center justify-between">