Fix todos and delete useProcessesLogs (vibe-kanban) (#755)

* Perfect! The implementation is complete and working. Here's a summary of what I accomplished:

##  Task Complete: Fixed TodoPanel and deleted useProcessesLogs

**Changes Made:**

1. **Created [`EntriesContext`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/contexts/EntriesContext.tsx)** - A shared context with performance optimizations using `useRef` to avoid unnecessary re-renders

2. **Modified [`TaskDetailsPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TaskDetailsPanel.tsx)** - Wrapped content with `EntriesProvider` to share state between VirtualizedList and TodoPanel

3. **Updated [`VirtualizedList`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/logs/VirtualizedList.tsx)** - Now writes entries to shared context via `onEntriesUpdated` callback

4. **Simplified [`TodoPanel`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/components/tasks/TodoPanel.tsx)** - Removed useProcessesLogs dependency, now reads entries directly from context

5. **Deleted `useProcessesLogs.ts`** - Hook is no longer needed as entries come from the shared context

**Architecture Benefits:**
- **Single source of truth** for entries data
- **Eliminates duplicate network streams** and SSE connections
- **Better performance** with optimized context updates
- **Scalable** for future widgets that need entry data
- **Clean separation** between data producers (VirtualizedList) and consumers (TodoPanel)

The build passes TypeScript checking and the implementation follows React best practices with performance optimizations recommended by the Oracle.

* Perfect! The implementation is complete and working. Here's what I accomplished:

##  Task Complete: Made `usePinnedTodos` typesafe

**Changes Made:**

1. **Updated [`usePinnedTodos`](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/vk-3135-fix-todos/frontend/src/hooks/usePinnedTodos.ts) parameter type** - Changed from `entries: any[]` to `entries: PatchTypeWithKey[]`

2. **Fixed entry structure logic:**
   - Changed `entry.channel === 'normalized'` → `entry.type === 'NORMALIZED_ENTRY'`
   - Changed `entry.payload` → `entry.content`

3. **Added proper TypeScript imports** - Imported `PatchTypeWithKey` type for proper type checking

4. **Fixed lint issues** - Updated TodoPanel parameter to avoid empty object pattern error

**Type Safety Benefits:**
- **Eliminates `any[]` usage** - Hook now has explicit typing
- **Prevents runtime errors** - TypeScript will catch incorrect data structure usage
- **Better IDE support** - Autocompletion and type checking now work properly
- **Aligned with new architecture** - Hook now correctly works with `PatchTypeWithKey[]` from `EntriesContext`

The refactor was simple and effective - the hook now properly handles the new data structure from `EntriesContext` while maintaining all its existing functionality with full type safety.

* Excellent! All the lint errors have been fixed. Here's a summary of what I accomplished:

**Changes Made:**

1. **Removed unused TodoPanelProps** - Since TodoPanel no longer accepts any props after the context refactor, I removed the interface entirely

2. **Updated TodoPanel signature** - Changed from `TodoPanel({}: TodoPanelProps)` to `TodoPanel()` to avoid unused parameter lint errors

3. **Updated TodoPanel usage** - Removed `selectedAttempt` prop from `<TodoPanel />` in TaskDetailsPanel since it's no longer needed

4. **Removed unused imports** - Removed `TaskAttempt` type import since it's no longer used

**Results:**
- **0 errors** (down from 2 errors)
- **96 warnings** (unchanged - these are mostly `any` types and dependency array issues that are not errors)
- TypeScript compilation passes
- Backend compilation passes

The implementation is clean and all lint errors have been resolved. The remaining warnings are pre-existing and not related to our changes.

* useState internally

* fmt
This commit is contained in:
Louis Knight-Webb
2025-09-18 11:22:10 +01:00
committed by GitHub
parent cc66eb96a3
commit a069304f25
6 changed files with 193 additions and 261 deletions

View File

@@ -8,6 +8,7 @@ import {
} from '@virtuoso.dev/message-list'; } from '@virtuoso.dev/message-list';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import DisplayConversationEntry from '../NormalizedConversation/DisplayConversationEntry'; import DisplayConversationEntry from '../NormalizedConversation/DisplayConversationEntry';
import { useEntries } from '@/contexts/EntriesContext';
import { import {
AddEntryType, AddEntryType,
PatchTypeWithKey, PatchTypeWithKey,
@@ -69,11 +70,13 @@ const ItemContent: VirtuosoMessageListProps<
const VirtualizedList = ({ attempt }: VirtualizedListProps) => { const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
const [channelData, setChannelData] = useState<ChannelData>(null); const [channelData, setChannelData] = useState<ChannelData>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { setEntries, reset } = useEntries();
// When attempt changes, set loading // When attempt changes, set loading and reset entries
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
}, [attempt.id]); reset();
}, [attempt.id, reset]);
const onEntriesUpdated = ( const onEntriesUpdated = (
newEntries: PatchTypeWithKey[], newEntries: PatchTypeWithKey[],
@@ -88,6 +91,7 @@ const VirtualizedList = ({ attempt }: VirtualizedListProps) => {
} }
setChannelData({ data: newEntries, scrollModifier }); setChannelData({ data: newEntries, scrollModifier });
setEntries(newEntries); // Update shared context
if (loading) { if (loading) {
setLoading(newLoading); setLoading(newLoading);
} }

View File

@@ -19,6 +19,7 @@ import TodoPanel from '@/components/tasks/TodoPanel';
import { TabNavContext } from '@/contexts/TabNavigationContext'; import { TabNavContext } from '@/contexts/TabNavigationContext';
import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext'; import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
import { ReviewProvider } from '@/contexts/ReviewProvider'; import { ReviewProvider } from '@/contexts/ReviewProvider';
import { EntriesProvider } from '@/contexts/EntriesContext';
import { AttemptHeaderCard } from './AttemptHeaderCard'; import { AttemptHeaderCard } from './AttemptHeaderCard';
import { inIframe } from '@/vscode/bridge'; import { inIframe } from '@/vscode/bridge';
import { TaskRelationshipViewer } from './TaskRelationshipViewer'; import { TaskRelationshipViewer } from './TaskRelationshipViewer';
@@ -117,87 +118,136 @@ export function TaskDetailsPanel({
<TabNavContext.Provider value={{ activeTab, setActiveTab }}> <TabNavContext.Provider value={{ activeTab, setActiveTab }}>
<ProcessSelectionProvider> <ProcessSelectionProvider>
<ReviewProvider> <ReviewProvider>
{/* Backdrop - only on smaller screens (overlay mode) */} <EntriesProvider>
{!hideBackdrop && ( {/* Backdrop - only on smaller screens (overlay mode) */}
{!hideBackdrop && (
<div
className={getBackdropClasses(isFullScreen || false)}
onClick={onClose}
/>
)}
{/* Panel */}
<div <div
className={getBackdropClasses(isFullScreen || false)} className={
onClick={onClose} className || getTaskPanelClasses(isFullScreen || false)
/> }
)} >
<div className={getTaskPanelInnerClasses()}>
{!inIframe() && (
<TaskDetailsHeader
task={task}
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
hideCloseButton={hideBackdrop}
isFullScreen={isFullScreen}
/>
)}
{/* Panel */} {isFullScreen ? (
<div <div className="flex-1 min-h-0 flex">
className={ {/* Sidebar */}
className || getTaskPanelClasses(isFullScreen || false) <aside
} className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`}
> >
<div className={getTaskPanelInnerClasses()}> {/* Fullscreen sidebar shows title and description above edit/delete */}
{!inIframe() && ( <div className="space-y-2 p-3">
<TaskDetailsHeader <TaskTitleDescription task={task} />
task={task} </div>
onClose={onClose}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
hideCloseButton={hideBackdrop}
isFullScreen={isFullScreen}
/>
)}
{isFullScreen ? ( {/* Current Attempt / Actions */}
<div className="flex-1 min-h-0 flex"> <TaskDetailsToolbar
{/* Sidebar */} task={task}
<aside projectId={projectId}
className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`} projectHasDevScript={projectHasDevScript}
> forceCreateAttempt={forceCreateAttempt}
{/* Fullscreen sidebar shows title and description above edit/delete */} onLeaveForceCreateAttempt={
<div className="space-y-2 p-3"> onLeaveForceCreateAttempt
<TaskTitleDescription task={task} /> }
</div> attempts={attempts}
selectedAttempt={selectedAttempt}
setSelectedAttempt={setSelectedAttempt}
// hide actions in sidebar; moved to header in fullscreen
/>
{/* Current Attempt / Actions */} {/* Task Breakdown (TODOs) */}
<TaskDetailsToolbar <TodoPanel />
task={task}
projectId={projectId}
projectHasDevScript={projectHasDevScript}
forceCreateAttempt={forceCreateAttempt}
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
attempts={attempts}
selectedAttempt={selectedAttempt}
setSelectedAttempt={setSelectedAttempt}
// hide actions in sidebar; moved to header in fullscreen
/>
{/* Task Breakdown (TODOs) */} {/* Task Relationships */}
<TodoPanel selectedAttempt={selectedAttempt} /> <TaskRelationshipViewer
selectedAttempt={selectedAttempt}
onNavigateToTask={onNavigateToTask}
task={task}
tasksById={tasksById}
/>
</aside>
{/* Task Relationships */} {/* Main content */}
<TaskRelationshipViewer <main className="flex-1 min-h-0 min-w-0 flex flex-col">
selectedAttempt={selectedAttempt} {selectedAttempt && (
onNavigateToTask={onNavigateToTask} <>
task={task} <TabNavigation
tasksById={tasksById} activeTab={activeTab}
/> setActiveTab={setActiveTab}
</aside> selectedAttempt={selectedAttempt}
/>
{/* Main content */} <div className="flex-1 flex flex-col min-h-0">
<main className="flex-1 min-h-0 min-w-0 flex flex-col"> {activeTab === 'diffs' ? (
{selectedAttempt && ( <DiffTab selectedAttempt={selectedAttempt} />
) : activeTab === 'processes' ? (
<ProcessesTab
attemptId={selectedAttempt?.id}
/>
) : (
<LogsTab selectedAttempt={selectedAttempt} />
)}
</div>
<TaskFollowUpSection
task={task}
selectedAttemptId={selectedAttempt?.id}
jumpToLogsTab={jumpToLogsTab}
/>
</>
)}
</main>
</div>
) : (
<>
{attempts.length === 0 ? (
<TaskDetailsToolbar
task={task}
projectId={projectId}
projectHasDevScript={projectHasDevScript}
forceCreateAttempt={forceCreateAttempt}
onLeaveForceCreateAttempt={
onLeaveForceCreateAttempt
}
attempts={attempts}
selectedAttempt={selectedAttempt}
setSelectedAttempt={setSelectedAttempt}
// hide actions in sidebar; moved to header in fullscreen
/>
) : (
<> <>
<TabNavigation <AttemptHeaderCard
activeTab={activeTab} attemptNumber={attemptNumber}
setActiveTab={setActiveTab} totalAttempts={attempts.length}
selectedAttempt={selectedAttempt} selectedAttempt={selectedAttempt}
task={task}
projectId={projectId}
// onCreateNewAttempt={() => {
// // TODO: Implement create new attempt
// console.log('Create new attempt');
// }}
onJumpToDiffFullScreen={jumpToDiffFullScreen}
/> />
<div className="flex-1 flex flex-col min-h-0"> {selectedAttempt && (
{activeTab === 'diffs' ? ( <LogsTab selectedAttempt={selectedAttempt} />
<DiffTab selectedAttempt={selectedAttempt} /> )}
) : activeTab === 'processes' ? (
<ProcessesTab attemptId={selectedAttempt?.id} />
) : (
<LogsTab selectedAttempt={selectedAttempt} />
)}
</div>
<TaskFollowUpSection <TaskFollowUpSection
task={task} task={task}
@@ -206,52 +256,11 @@ export function TaskDetailsPanel({
/> />
</> </>
)} )}
</main> </>
</div> )}
) : ( </div>
<>
{attempts.length === 0 ? (
<TaskDetailsToolbar
task={task}
projectId={projectId}
projectHasDevScript={projectHasDevScript}
forceCreateAttempt={forceCreateAttempt}
onLeaveForceCreateAttempt={onLeaveForceCreateAttempt}
attempts={attempts}
selectedAttempt={selectedAttempt}
setSelectedAttempt={setSelectedAttempt}
// hide actions in sidebar; moved to header in fullscreen
/>
) : (
<>
<AttemptHeaderCard
attemptNumber={attemptNumber}
totalAttempts={attempts.length}
selectedAttempt={selectedAttempt}
task={task}
projectId={projectId}
// onCreateNewAttempt={() => {
// // TODO: Implement create new attempt
// console.log('Create new attempt');
// }}
onJumpToDiffFullScreen={jumpToDiffFullScreen}
/>
{selectedAttempt && (
<LogsTab selectedAttempt={selectedAttempt} />
)}
<TaskFollowUpSection
task={task}
selectedAttemptId={selectedAttempt?.id}
jumpToLogsTab={jumpToLogsTab}
/>
</>
)}
</>
)}
</div> </div>
</div> </EntriesProvider>
</ReviewProvider> </ReviewProvider>
</ProcessSelectionProvider> </ProcessSelectionProvider>
</TabNavContext.Provider> </TabNavContext.Provider>

View File

@@ -1,10 +1,6 @@
import { useMemo } from 'react';
import { Circle, CircleCheckBig, CircleDotDashed } from 'lucide-react'; import { Circle, CircleCheckBig, CircleDotDashed } from 'lucide-react';
import { useProcessesLogs } from '@/hooks/useProcessesLogs'; import { useEntries } from '@/contexts/EntriesContext';
import { usePinnedTodos } from '@/hooks/usePinnedTodos'; import { usePinnedTodos } from '@/hooks/usePinnedTodos';
import { useAttemptExecution } from '@/hooks';
import { shouldShowInLogs } from '@/constants/processes';
import type { TaskAttempt } from 'shared/types';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
function getStatusIcon(status?: string) { function getStatusIcon(status?: string) {
@@ -16,26 +12,8 @@ function getStatusIcon(status?: string) {
return <Circle aria-hidden className="h-4 w-4 text-muted-foreground" />; return <Circle aria-hidden className="h-4 w-4 text-muted-foreground" />;
} }
interface TodoPanelProps { export function TodoPanel() {
selectedAttempt: TaskAttempt | null; const { entries } = useEntries();
}
export function TodoPanel({ selectedAttempt }: TodoPanelProps) {
const { attemptData } = useAttemptExecution(selectedAttempt?.id);
const filteredProcesses = useMemo(
() =>
(attemptData.processes || []).filter(
(p) => shouldShowInLogs(p.run_reason) && !p.dropped
),
[
attemptData.processes
?.map((p) => `${p.id}:${p.status}:${p.dropped}`)
.join(','),
]
);
const { entries } = useProcessesLogs(filteredProcesses, true);
const { todos } = usePinnedTodos(entries); const { todos } = usePinnedTodos(entries);
// Only show once the agent has created subtasks // Only show once the agent has created subtasks

View File

@@ -0,0 +1,54 @@
import {
createContext,
useContext,
useState,
useMemo,
useCallback,
ReactNode,
} from 'react';
import type { PatchTypeWithKey } from '@/hooks/useConversationHistory';
interface EntriesContextType {
entries: PatchTypeWithKey[];
setEntries: (entries: PatchTypeWithKey[]) => void;
reset: () => void;
}
const EntriesContext = createContext<EntriesContextType | null>(null);
interface EntriesProviderProps {
children: ReactNode;
}
export const EntriesProvider = ({ children }: EntriesProviderProps) => {
const [entries, setEntriesState] = useState<PatchTypeWithKey[]>([]);
const setEntries = useCallback((newEntries: PatchTypeWithKey[]) => {
setEntriesState(newEntries);
}, []);
const reset = useCallback(() => {
setEntriesState([]);
}, []);
const value = useMemo(
() => ({
entries,
setEntries,
reset,
}),
[entries, setEntries, reset]
);
return (
<EntriesContext.Provider value={value}>{children}</EntriesContext.Provider>
);
};
export const useEntries = (): EntriesContextType => {
const context = useContext(EntriesContext);
if (!context) {
throw new Error('useEntries must be used within an EntriesProvider');
}
return context;
};

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { TodoItem } from 'shared/types'; import type { TodoItem } from 'shared/types';
import type { PatchTypeWithKey } from '@/hooks/useConversationHistory';
interface UsePinnedTodosResult { interface UsePinnedTodosResult {
todos: TodoItem[]; todos: TodoItem[];
@@ -10,14 +11,16 @@ interface UsePinnedTodosResult {
* Hook that extracts and maintains the latest TODO state from normalized conversation entries. * Hook that extracts and maintains the latest TODO state from normalized conversation entries.
* Filters for TodoManagement ActionType entries and returns the most recent todo list. * Filters for TodoManagement ActionType entries and returns the most recent todo list.
*/ */
export const usePinnedTodos = (entries: any[]): UsePinnedTodosResult => { export const usePinnedTodos = (
entries: PatchTypeWithKey[]
): UsePinnedTodosResult => {
return useMemo(() => { return useMemo(() => {
let latestTodos: TodoItem[] = []; let latestTodos: TodoItem[] = [];
let lastUpdatedTime: string | null = null; let lastUpdatedTime: string | null = null;
for (const entry of entries) { for (const entry of entries) {
if (entry.channel === 'normalized' && entry.payload) { if (entry.type === 'NORMALIZED_ENTRY' && entry.content) {
const normalizedEntry = entry.payload as any; const normalizedEntry = entry.content as any;
if ( if (
normalizedEntry.entry_type?.type === 'tool_use' && normalizedEntry.entry_type?.type === 'tool_use' &&

View File

@@ -1,116 +0,0 @@
import { useMemo, useCallback } from 'react';
import type {
ExecutionProcess,
NormalizedEntry,
PatchType,
} from 'shared/types';
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
import { useEventSourceManager } from './useEventSourceManager';
interface UseProcessesLogsResult {
entries: UnifiedLogEntry[];
isConnected: boolean;
error: string | null;
}
const MAX_ENTRIES = 5000;
export const useProcessesLogs = (
processes: ExecutionProcess[],
enabled: boolean
): UseProcessesLogsResult => {
const getEndpoint = useCallback((process: ExecutionProcess) => {
// Coding agents use normalized logs endpoint, scripts use raw logs endpoint
// Both endpoints now return PatchType objects via JSON patches
const isCodingAgent = process.run_reason === 'codingagent';
return isCodingAgent
? `/api/execution-processes/${process.id}/normalized-logs`
: `/api/execution-processes/${process.id}/raw-logs`;
}, []);
const initialData = useMemo(() => ({ entries: [] }), []);
const { processData, isConnected, error } = useEventSourceManager({
processes,
enabled,
getEndpoint,
initialData,
});
const entries = useMemo(() => {
const allEntries: UnifiedLogEntry[] = [];
let entryCounter = 0;
// Iterate through processes in order, adding process marker followed by logs
processes.forEach((process) => {
const data = processData[process.id];
if (!data?.entries) return;
// Add process start marker first
const processStartPayload: ProcessStartPayload = {
processId: process.id,
runReason: process.run_reason,
startedAt: process.started_at,
status: process.status,
action: process.executor_action,
};
allEntries.push({
id: `${process.id}-start`,
ts: entryCounter++,
processId: process.id,
processName: process.run_reason,
channel: 'process_start',
payload: processStartPayload,
});
// Then add all logs for this process (skip the injected PROCESS_START entry)
data.entries.forEach(
(
patchEntry:
| PatchType
| { type: 'PROCESS_START'; content: ProcessStartPayload },
index: number
) => {
// Skip the injected PROCESS_START entry since we handle it above
if (patchEntry.type === 'PROCESS_START') return;
let channel: UnifiedLogEntry['channel'];
let payload: string | NormalizedEntry;
switch (patchEntry.type) {
case 'STDOUT':
channel = 'stdout';
payload = patchEntry.content;
break;
case 'STDERR':
channel = 'stderr';
payload = patchEntry.content;
break;
case 'NORMALIZED_ENTRY':
channel = 'normalized';
payload = patchEntry.content;
break;
default:
// Skip unknown patch types
return;
}
allEntries.push({
id: `${process.id}-${index}`,
ts: entryCounter++,
processId: process.id,
processName: process.run_reason,
channel,
payload,
});
}
);
});
// Limit entries (no sorting needed since we build in order)
return allEntries.slice(-MAX_ENTRIES);
}, [processData, processes]);
return { entries, isConnected, error };
};