-
Notifications
You must be signed in to change notification settings - Fork 0
Optimistic UI Updates for Instant Feedback #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
83da02b
665a190
bcea22e
0aee791
833ed24
377f782
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -203,6 +203,120 @@ | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates an optimistic update action for instant UI feedback with automatic revert on error | ||
| * | ||
| * This helper follows the pattern from UsageIndicator.tsx: | ||
| * 1. Capture previous state for potential revert | ||
| * 2. Apply optimistic update immediately for instant feedback | ||
| * 3. Execute async operation | ||
| * 4. Revert to previous state if operation fails | ||
| * | ||
| * @param getState - Function to get current state | ||
| * @param setState - Function to update state | ||
| * @param optimisticUpdate - Function to calculate optimistic state from current state | ||
| * @param asyncOperation - The async operation to perform (e.g., API call) | ||
| * @param onError - Optional custom error handler (default: console.error) | ||
| * @returns Promise with the result of the async operation | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await createOptimisticAction( | ||
| * () => useTaskStore.getState(), | ||
| * (state) => useTaskStore.setState(state), | ||
| * (state) => ({ | ||
| * ...state, | ||
| * tasks: state.tasks.map(t => t.id === taskId ? { ...t, status: 'done' } : t) | ||
| * }), | ||
| * async () => { | ||
| * await window.electronAPI.updateTaskStatus(taskId, 'done'); | ||
| * } | ||
| * ); | ||
| * ``` | ||
| */ | ||
| export async function createOptimisticAction<T>( | ||
| getState: () => T, | ||
| setState: (state: T | Partial<T>) => void, | ||
| optimisticUpdate: (currentState: T) => T | Partial<T>, | ||
| asyncOperation: () => Promise<void>, | ||
| onError?: (error: Error) => void | ||
| ): Promise<void> { | ||
| // Capture previous state for revert (before any changes) | ||
| const previousState = getState(); | ||
|
|
||
| try { | ||
| // Calculate and apply optimistic update immediately | ||
| const optimisticState = optimisticUpdate(previousState); | ||
| setState(optimisticState); | ||
|
|
||
| // Execute the actual async operation | ||
| await asyncOperation(); | ||
| } catch (error) { | ||
| // Revert to captured previous state on error | ||
| setState(previousState); | ||
|
|
||
| // Call custom error handler or default to console.error | ||
| if (onError) { | ||
| onError(error as Error); | ||
| } else { | ||
| console.error('[createOptimisticAction] Operation failed, reverted state:', error); | ||
| } | ||
|
|
||
| // Re-throw for caller to handle if needed | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Creates an optimistic action specifically for task updates | ||
| * Convenience wrapper around createOptimisticAction for task-store operations | ||
| * | ||
| * @param taskId - The task ID to update | ||
| * @param optimisticUpdate - Function to calculate optimistic task state | ||
| * @param asyncOperation - The async operation to perform | ||
| * @param onError - Optional custom error handler | ||
| * @returns Promise that resolves when operation completes | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * await createOptimisticTaskAction( | ||
| * taskId, | ||
| * (task) => ({ ...task, status: 'done' }), | ||
| * async () => { | ||
| * await window.electronAPI.completeTask(taskId); | ||
| * } | ||
| * ); | ||
| * ``` | ||
| */ | ||
| export async function createOptimisticTaskAction( | ||
| taskId: string, | ||
| optimisticUpdate: (task: Task) => Partial<Task>, | ||
| asyncOperation: () => Promise<void>, | ||
| onError?: (error: Error) => void | ||
| ): Promise<void> { | ||
| await createOptimisticAction( | ||
| () => useTaskStore.getState(), | ||
| (state) => useTaskStore.setState(state), | ||
| (state) => { | ||
| const taskIndex = findTaskIndex(state.tasks, taskId); | ||
| if (taskIndex === -1) { | ||
| debugLog('[createOptimisticTaskAction] Task not found:', taskId); | ||
| return state; | ||
| } | ||
|
|
||
| const updatedTasks = updateTaskAtIndex( | ||
| state.tasks, | ||
| taskIndex, | ||
| (task) => ({ ...task, ...optimisticUpdate(task) }) | ||
| ); | ||
|
|
||
| return { ...state, tasks: updatedTasks }; | ||
| }, | ||
| asyncOperation, | ||
| onError | ||
| ); | ||
| } | ||
|
|
||
| export const useTaskStore = create<TaskState>((set, get) => ({ | ||
| tasks: [], | ||
| selectedTaskId: null, | ||
|
|
@@ -829,6 +943,64 @@ | |
| window.electronAPI.stopTask(taskId); | ||
| } | ||
|
|
||
| /** | ||
| * Start a task with optimistic UI update | ||
| * Immediately updates the task status to 'in_progress' before server confirmation, | ||
| * with automatic rollback on error. | ||
| * | ||
| * @param taskId - The task ID to start | ||
| * @param options - Optional configuration (parallel execution, worker count) | ||
| * @throws Error if task not found or start operation fails | ||
| */ | ||
| export async function startTaskOptimistic( | ||
| taskId: string, | ||
| options?: { parallel?: boolean; workers?: number } | ||
| ): Promise<void> { | ||
| await createOptimisticTaskAction( | ||
| taskId, | ||
| (task) => { | ||
| // Optimistic update: change status to 'in_progress' | ||
| return { | ||
| status: 'in_progress' as const, | ||
| }; | ||
| }, | ||
| async () => { | ||
| // Call the backend API to start the task | ||
| await window.electronAPI.startTask(taskId, options); | ||
|
Check failure on line 969 in apps/frontend/src/renderer/stores/task-store.ts
|
||
| }, | ||
| (error) => { | ||
| console.error('[startTaskOptimistic] Failed to start task:', error); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Stop a task with optimistic UI update | ||
| * Immediately updates the task status back to 'backlog' | ||
| * before server confirmation, with automatic rollback on error. | ||
| * | ||
| * @param taskId - The task ID to stop | ||
| * @throws Error if task not found or stop operation fails | ||
| */ | ||
| export async function stopTaskOptimistic(taskId: string): Promise<void> { | ||
| await createOptimisticTaskAction( | ||
| taskId, | ||
| (task) => { | ||
| // Optimistic update: revert to 'backlog' (most common pre-start status) | ||
| return { | ||
| status: 'backlog' as const, | ||
| }; | ||
| }, | ||
| async () => { | ||
| // Call the backend API to stop the task | ||
| await window.electronAPI.stopTask(taskId); | ||
|
Check failure on line 996 in apps/frontend/src/renderer/stores/task-store.ts
|
||
| }, | ||
| (error) => { | ||
| console.error('[stopTaskOptimistic] Failed to stop task:', error); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Submit review for a task | ||
| */ | ||
|
|
@@ -1061,6 +1233,43 @@ | |
| // Task Creation Draft Management | ||
| // ============================================ | ||
|
|
||
| /** | ||
| * Archive a single task with optimistic UI update | ||
| * Immediately adds archivedAt timestamp before server confirmation, | ||
| * with automatic rollback on error. | ||
| * | ||
| * @param projectId - The project ID | ||
| * @param taskId - The task ID to archive | ||
| * @throws Error if task not found or archive operation fails | ||
| */ | ||
| export async function archiveTaskOptimistic( | ||
| projectId: string, | ||
| taskId: string | ||
| ): Promise<void> { | ||
| await createOptimisticTaskAction( | ||
| taskId, | ||
| (task) => { | ||
| // Optimistic update: add archivedAt timestamp | ||
| return { | ||
| metadata: { | ||
| ...task.metadata, | ||
| archivedAt: new Date().toISOString(), | ||
| }, | ||
| }; | ||
| }, | ||
| async () => { | ||
| // Call the backend API to archive the task | ||
| const result = await window.electronAPI.archiveTasks(projectId, [taskId]); | ||
|
Check warning on line 1262 in apps/frontend/src/renderer/stores/task-store.ts
|
||
| if (!result.success) { | ||
| throw new Error(result.error || 'Failed to archive task'); | ||
| } | ||
| }, | ||
| (error) => { | ||
| console.error('[archiveTaskOptimistic] Failed to archive task:', error); | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| const DRAFT_KEY_PREFIX = 'task-creation-draft'; | ||
|
|
||
| /** | ||
|
|
||
Check notice
Code scanning / CodeQL
Unused variable, import, function or class Note
Copilot Autofix
AI 3 months ago
In general, unused variables should be removed to keep the code clear and avoid misleading future readers into thinking the value is important. Here,
previousStatusis assigned fromtask.statusbut never used, so the best fix is simply to delete that declaration.Concretely, in
apps/frontend/src/renderer/components/KanbanBoard.tsx, within thehandleStatusChangefunction around line 995, remove the lineconst previousStatus = task.status;. No additional imports, methods, or definitions are required, and no other code within the snippet needs to be adjusted becausepreviousStatusis not referenced anywhere.