Skip to content
Open
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
4 changes: 2 additions & 2 deletions frontend/src/apis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ class APIClient {
}
}

async get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' })
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
return this.request<T>(endpoint, { method: 'GET', ...options })
Comment on lines +123 to +124
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, verify the actual current state of the get() method at lines 123-124
cat -n frontend/src/apis/client.ts | sed -n '120,130p'

Repository: wecode-ai/Wegent

Length of output: 468


🏁 Script executed:

# Search for all callsites of apiClient.get with method in options
rg --type=ts --type=tsx 'apiClient\.get\s*\(' -A 3 | head -100

Repository: wecode-ai/Wegent

Length of output: 87


🏁 Script executed:

# Also check for direct usage of this.request and get patterns
rg --type=ts 'method.*GET' frontend/src/apis/client.ts -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 203


🏁 Script executed:

# Search for all get() calls in the codebase
rg 'apiClient\.get\(' --type=ts -B 1 -A 3 | head -150

Repository: wecode-ai/Wegent

Length of output: 8059


🏁 Script executed:

# Also search for imports and usage patterns of the APIClient class
rg 'new APIClient\|import.*APIClient' --type=ts -B 1 -A 3

Repository: wecode-ai/Wegent

Length of output: 42


🏁 Script executed:

# Check if there are any tests for the get method
fd -e test.ts -e spec.ts -e test.js -e spec.js | xargs rg -l "\.get\(" | head -5

Repository: wecode-ai/Wegent

Length of output: 258


🏁 Script executed:

# Verify the actual behavior of spread ordering in JavaScript objects to be thorough
python3 << 'EOF'
# Demonstrate that last property definition wins in object spread
test1 = {'method': 'GET'}
test2 = {'signal': 'abort_signal'}
merged = {**test1, **test2}
print(f"Test 1 (method first): {merged}")

merged2 = {**test2, **test1}
print(f"Test 2 (method last): {merged2}")

# Simulate the vulnerability
request_init = {'method': 'GET'}
options = {'method': 'POST', 'signal': 'abort_signal'}
result = {**request_init, **options}
print(f"Vulnerability test (options override): {result}")
EOF

Repository: wecode-ai/Wegent

Length of output: 276


Prevent get() callers from overriding the HTTP method.

At line 124, { method: 'GET', ...options } allows options.method to silently change this into a non-GET request. While no current callsite exploits this, it breaks the semantic contract of the method.

🔧 Proposed fix
-  async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
-    return this.request<T>(endpoint, { method: 'GET', ...options })
+  async get<T>(endpoint: string, options?: Omit<RequestInit, 'method' | 'body'>): Promise<T> {
+    return this.request<T>(endpoint, { ...options, method: 'GET' })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/apis/client.ts` around lines 123 - 124, The get<T> method
currently calls request<T>(endpoint, { method: 'GET', ...options }) which lets
callers override the HTTP method via options.method; change the merge so the
explicit GET wins (e.g., pass options first and then method or set method after
spreading) so request<T> always receives method: 'GET' regardless of options;
update the get<T> implementation (the get function and its call to request<T>)
to enforce method immutability.

}

async post<T>(endpoint: string, data?: unknown): Promise<T> {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/apis/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,8 @@ export const taskApis = {
return apiClient.put(`/tasks/${id}`, data)
},

getTaskDetail: async (id: number): Promise<TaskDetail> => {
return apiClient.get(`/tasks/${id}`)
getTaskDetail: async (id: number, signal?: AbortSignal): Promise<TaskDetail> => {
return apiClient.get(`/tasks/${id}`, signal ? { signal } : undefined)
},

// Send a message. If task_id not provided, create task first, then send.
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/app/(tasks)/chat/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2025 Weibo, Inc.
//
// SPDX-License-Identifier: Apache-2.0

'use client'

import { useEffect } from 'react'
import { useTranslation } from '@/hooks/useTranslation'
import { Button } from '@/components/ui/button'

/**
* Next.js error boundary for the chat route.
* Catches rendering errors in ChatPageDesktop/ChatPageMobile and shows
* a recovery UI instead of a blank white screen.
*/
export default function ChatError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const { t } = useTranslation('common')

useEffect(() => {
console.error('[ChatError] Caught rendering error:', error)
}, [error])

return (
<div className="flex h-full w-full items-center justify-center bg-base">
<div className="flex flex-col items-center gap-4 text-center">
<p className="text-sm text-text-secondary">{t('errors.request_failed')}</p>
<Button variant="primary" onClick={reset}>
{t('actions.retry')}
</Button>
Comment on lines +33 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Ensure retry button meets mobile tap-target size requirement.

At Line [33], the button does not explicitly enforce the required 44×44 minimum target size.

📱 Suggested fix
-        <Button variant="primary" onClick={reset}>
+        <Button variant="primary" onClick={reset} className="h-11 min-w-[44px]">
           {t('actions.retry')}
         </Button>
As per coding guidelines, "All interactive elements on mobile must be at least 44px × 44px - use `h-11 min-w-[44px]` for buttons".
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button variant="primary" onClick={reset}>
{t('actions.retry')}
</Button>
<Button variant="primary" onClick={reset} className="h-11 min-w-[44px]">
{t('actions.retry')}
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/app/`(tasks)/chat/error.tsx around lines 33 - 35, The retry
Button used in the error UI doesn't enforce the 44×44 mobile tap target; update
the Button element (the one with props variant="primary" and onClick={reset}
rendering {t('actions.retry')}) to include the recommended sizing utility
classes (e.g., h-11 and min-w-[44px]) so it meets the mobile minimum tap target
requirement.

</div>
</div>
)
}
11 changes: 11 additions & 0 deletions frontend/src/app/(tasks)/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,29 @@ import { useIsMobile } from '@/features/layout/hooks/useMediaQuery'
import { useUser } from '@/features/common/UserContext'
import { useTaskContext } from '@/features/tasks/contexts/taskContext'

// Loading skeleton for dynamic chat page imports
function ChatPageSkeleton() {
return (
<div className="flex h-full w-full items-center justify-center bg-base">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-primary" />
</div>
)
}

// Dynamic imports for mobile and desktop page components with code splitting
const ChatPageDesktop = dynamic(
() => import('./ChatPageDesktop').then(mod => ({ default: mod.ChatPageDesktop })),
{
ssr: false,
loading: () => <ChatPageSkeleton />,
}
)

const ChatPageMobile = dynamic(
() => import('./ChatPageMobile').then(mod => ({ default: mod.ChatPageMobile })),
{
ssr: false,
loading: () => <ChatPageSkeleton />,
}
)

Expand Down
40 changes: 9 additions & 31 deletions frontend/src/features/tasks/components/chat/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useTranslation } from '@/hooks/useTranslation'
import { useRouter } from 'next/navigation'
import { useTaskContext } from '../../contexts/taskContext'
import { useTaskStateMachine } from '../../hooks/useTaskStateMachine'
import { useHasMessages } from '../../hooks/useHasMessages'
import { Button } from '@/components/ui/button'
import { useScrollManagement } from '../hooks/useScrollManagement'
import { useFloatingInput } from '../hooks/useFloatingInput'
Expand Down Expand Up @@ -97,7 +98,8 @@ function ChatAreaContent({
const { quote, clearQuote, formatQuoteForMessage } = useQuote()

// Task context
const { selectedTaskDetail, setSelectedTask, accessDenied } = useTaskContext()
const { selectedTask, selectedTaskDetail, setSelectedTask, accessDenied, clearAccessDenied } =
useTaskContext()

// Use useTaskStateMachine hook for reactive state updates (SINGLE SOURCE OF TRUTH per AGENTS.md)
const { state: taskState } = useTaskStateMachine(selectedTaskDetail?.id)
Expand Down Expand Up @@ -499,37 +501,13 @@ function ChatAreaContent({
teams: [...filteredTeams, ...teams],
})

// Determine if there are messages to display (full computation)
// Note: Now using taskState.messages from state machine instead of selectedTaskDetail.subtasks
const hasMessages = useMemo(() => {
const hasSelectedTask = selectedTaskDetail && selectedTaskDetail.id
const hasNewTaskStream =
!selectedTaskDetail?.id && streamHandlers.pendingTaskId && streamHandlers.isStreaming
const hasLocalPending = streamHandlers.localPendingMessage !== null
// Use taskState from state machine (single source of truth)
const hasUnifiedMessages = taskState?.messages && taskState.messages.size > 0

// If we have a selected task with messages in state machine, show messages
if (hasSelectedTask && hasUnifiedMessages) {
return true
}

return Boolean(
hasSelectedTask ||
streamHandlers.hasPendingUserMessage ||
streamHandlers.isStreaming ||
hasNewTaskStream ||
hasLocalPending ||
hasUnifiedMessages
)
}, [
// Determine if there are messages to display
const hasMessages = useHasMessages({
selectedTask,
selectedTaskDetail,
streamHandlers.hasPendingUserMessage,
streamHandlers.isStreaming,
streamHandlers.pendingTaskId,
streamHandlers.localPendingMessage,
taskState?.messages,
])
taskState,
streamHandlers,
})
Comment on lines +504 to +510
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

hasMessages now diverges from the visibility value used by layout/scroll hooks.

useHasMessages includes streaming/pending states, but useScrollManagement and useFloatingInput still rely on hasMessagesForHooks (selected task detail + message map only). This can produce UI state mismatch (render says “has messages”, hooks behave as “no messages”) during early stream/pending phases.

Please make render + hooks consume one consistent visibility source.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/features/tasks/components/chat/ChatArea.tsx` around lines 411 -
417, The UI mismatch comes from render using hasMessages (from useHasMessages
which includes streaming/pending) while useScrollManagement and useFloatingInput
still consume hasMessagesForHooks (only selectedTaskDetail + message map); unify
them by creating and using a single visibility source (e.g., hasMessagesVisible)
and pass that same value to both render and hooks. Modify the call sites so
useHasMessages (or a small adapter) returns the canonical visibility boolean and
replace uses of hasMessagesForHooks in useScrollManagement and useFloatingInput
calls with that single hasMessagesVisible (or update
useScrollManagement/useFloatingInput to accept an explicit visibility flag
derived from useHasMessages), ensuring references to selectedTask,
selectedTaskDetail, taskState, streamHandlers and message map are preserved when
computing the unified value.


// Note: Team selection is now handled by useTeamSelection hook in TeamSelector component
// Model selection is handled by useModelSelection hook in ModelSelector component
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { GeminiAnnotations } from '../chat/GeminiAnnotations'
import CollapsibleMessage from './CollapsibleMessage'
import { processCitePatterns } from '../../utils/processCitePatterns'
import RegenerateModelPopover from './RegenerateModelPopover'
import { useCopyCleanup } from '../../hooks/useCopyCleanup'
import VideoConfigBadge from './VideoConfigBadge'
import type { ClarificationData, FinalPromptData, ClarificationAnswer } from '@/types/api'
import type { SourceReference, GeminiAnnotation } from '@/types/socket'
Expand Down Expand Up @@ -1327,6 +1328,9 @@ const MessageBubble = memo(
}
}

// Handle copy event to clean up extra newlines from DOM structure
const handleCopy = useCopyCleanup()

// When editing, expand to full width for better editing experience
const containerWidthClass = isEditing
? 'w-full'
Expand All @@ -1348,6 +1352,7 @@ const MessageBubble = memo(
<div
className={`${bubbleBaseClasses} ${bubbleTypeClasses}`}
onMouseUp={handleTextSelection}
onCopy={handleCopy}
data-message-content="true"
>
{/* Show header for AI messages */}
Expand Down
22 changes: 18 additions & 4 deletions frontend/src/features/tasks/contexts/taskContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export const TaskContextProvider = ({ children }: { children: ReactNode }) => {
// Track task status for notification
const taskStatusMapRef = useRef<Map<number, TaskStatus>>(new Map())

// AbortController for cancelling stale getTaskDetail requests during rapid task switching
const abortControllerRef = useRef<AbortController | null>(null)

// WebSocket connection for real-time task updates
const { registerTaskHandlers, isConnected, leaveTask, joinTask, onReconnect } = useSocket()

Expand Down Expand Up @@ -688,18 +691,27 @@ export const TaskContextProvider = ({ children }: { children: ReactNode }) => {
return
}

// Abort previous in-flight request to prevent stale responses overwriting current data
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const controller = new AbortController()
abortControllerRef.current = controller

try {
// Clear access denied state before fetching
setAccessDenied(false)
// Fetch task metadata only (subtasks are now obtained via WebSocket task:join)
const updatedTaskDetail = await taskApis.getTaskDetail(selectedTask.id)
const updatedTaskDetail = await taskApis.getTaskDetail(selectedTask.id, controller.signal)

// Note: Workbench data extraction from subtasks is no longer needed here
// Subtasks are now managed by TaskStateMachine via WebSocket join response
// Workbench data should be obtained from the state machine or WebSocket events
// Verify the selected task hasn't changed while the request was in flight
if (controller.signal.aborted) return

setSelectedTaskDetail(updatedTaskDetail)
} catch (error) {
// Ignore abort errors - they are expected when switching tasks rapidly
if (error instanceof DOMException && error.name === 'AbortError') return

// Check if it's a 403 Forbidden or 404 Not Found error (access denied or task not found)
// Both cases should show the access denied UI to prevent information leakage
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
Expand All @@ -722,6 +734,8 @@ export const TaskContextProvider = ({ children }: { children: ReactNode }) => {
// Leave previous task room if switching to a different task
if (previousTaskId !== null && previousTaskId !== currentTaskId) {
leaveTask(previousTaskId)
// Clear stale detail immediately so downstream hooks don't operate on wrong task data
setSelectedTaskDetail(null)
}
Comment on lines +737 to 739
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get overall file size and structure
wc -l frontend/src/features/tasks/contexts/taskContext.tsx
echo "---"
# Find all abort and setSelectedTaskDetail patterns
rg -n 'abortControllerRef|setSelectedTaskDetail' frontend/src/features/tasks/contexts/taskContext.tsx

Repository: wecode-ai/Wegent

Length of output: 670


Abort in-flight detail fetch when deselecting task or clearing selection.

When selectedTask becomes null (or falsy), the else branch executes setSelectedTaskDetail(null) at line 738 without aborting any in-flight request from the previous task. An earlier fetch can still resolve after this line and overwrite the cleared state with stale data.

Abort the controller before clearing detail in both the task-switch path and the deselect-to-null path:

🔧 Proposed fix
   if (previousTaskId !== null && previousTaskId !== currentTaskId) {
+    if (abortControllerRef.current) {
+      abortControllerRef.current.abort()
+      abortControllerRef.current = null
+    }
     leaveTask(previousTaskId)
     // Clear stale detail immediately so downstream hooks don't operate on wrong task data
     setSelectedTaskDetail(null)
   }
@@
   if (selectedTask) {
@@
   } else {
+    if (abortControllerRef.current) {
+      abortControllerRef.current.abort()
+      abortControllerRef.current = null
+    }
     setSelectedTaskDetail(null)
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/features/tasks/contexts/taskContext.tsx` around lines 737 - 739,
When selectedTask becomes falsy you currently call setSelectedTaskDetail(null)
without aborting any ongoing detail fetch, allowing a prior fetch to later
overwrite cleared state; fix this by calling abort() on the AbortController used
for task detail fetches (the controller/ref used in the task detail effect —
e.g., detailAbortController or abortControllerRef) before clearing state and
also aborting the previous controller in the task-switch path, then create a new
controller before starting any new fetch so stale responses cannot overwrite
setSelectedTaskDetail; apply these changes around the code that references
selectedTask and setSelectedTaskDetail.


// Update the ref to track current task
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/features/tasks/hooks/useCopyCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2025 Weibo, Inc.
//
// SPDX-License-Identifier: Apache-2.0

import { useCallback } from 'react'

/**
* Cleans up selected text by:
* - Replacing 2+ consecutive newlines with a single newline
* - Trimming leading/trailing whitespace
*/
export function cleanCopyText(text: string): string {
return text.replace(/\n{2,}/g, '\n').trim()
}
Comment on lines +12 to +14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

cleanCopyText currently removes meaningful formatting.

Line 13 collapses all multi-newline spacing and trims both ends, which can degrade copied markdown/code readability. Keep intentional paragraph/code spacing while still removing accidental wrapper noise.

Proposed fix
 export function cleanCopyText(text: string): string {
-  return text.replace(/\n{2,}/g, '\n').trim()
+  return text
+    .replace(/\r\n/g, '\n')
+    // Keep one blank line between blocks; only collapse excessive gaps
+    .replace(/\n{3,}/g, '\n\n')
+    // Remove only boundary newlines introduced by wrapper elements
+    .replace(/^\n+|\n+$/g, '')
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/features/tasks/hooks/useCopyCleanup.ts` around lines 12 - 14,
cleanCopyText currently collapses all multiple newlines and trims both ends,
which removes meaningful paragraph/code spacing; change it so it only strips
accidental wrapper noise (leading/trailing blank lines and surrounding
whitespace) while preserving internal multi-newline spacing. Update the
cleanCopyText implementation to remove leading and trailing blank lines (e.g.,
via a regex that targets /^\s*\n+|\n+\s*$/) and then optionally trim only the
overall ends, but do not collapse internal consecutive newlines so paragraphs
and code blocks remain intact.


/**
* Hook to handle copy events with text cleanup.
* Fixes the issue where block-level elements create extra newlines when copying.
*/
export function useCopyCleanup() {
const handleCopy = useCallback((e: React.ClipboardEvent) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return

const text = cleanCopyText(selection.toString())
e.clipboardData.setData('text/plain', text)
e.preventDefault()
}, [])

return handleCopy
}
71 changes: 71 additions & 0 deletions frontend/src/features/tasks/hooks/useHasMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2025 Weibo, Inc.
//
// SPDX-License-Identifier: Apache-2.0

import { useMemo } from 'react'
import type { TaskDetail } from '../../../types/api'
import type { TaskStateData } from '../state'

interface StreamHandlersState {
hasPendingUserMessage: boolean
isStreaming: boolean
pendingTaskId: number | null
localPendingMessage: unknown | null
}

interface UseHasMessagesParams {
selectedTask: { id: number } | null
selectedTaskDetail: TaskDetail | null
taskState: TaskStateData | null
streamHandlers: StreamHandlersState
}

/**
* Determines if there are messages to display in the chat area.
*
* Logic breakdown:
* - hasSelectedTask: Has loaded task detail with messages
* - isLoadingTask: Task is selected but details are still loading (prevents flash)
* - hasNewTaskStream: New task being created with streaming
* - hasLocalPending: Local pending message waiting to be sent
* - hasUnifiedMessages: Messages exist in state machine
*/
export function useHasMessages({
selectedTask,
selectedTaskDetail,
taskState,
streamHandlers,
}: UseHasMessagesParams): boolean {
return useMemo(() => {
const hasSelectedTask = selectedTaskDetail?.id != null
const isLoadingTask = selectedTask != null && selectedTaskDetail == null
const hasNewTaskStream =
!selectedTaskDetail?.id && streamHandlers.pendingTaskId != null && streamHandlers.isStreaming
const hasLocalPending = streamHandlers.localPendingMessage != null
const hasUnifiedMessages = taskState?.messages != null && taskState.messages.size > 0

// Fast path: task with messages loaded
if (hasSelectedTask && hasUnifiedMessages) {
return true
}

// Check any condition that indicates chat should be shown
return (
hasSelectedTask ||
isLoadingTask ||
streamHandlers.hasPendingUserMessage ||
streamHandlers.isStreaming ||
hasNewTaskStream ||
hasLocalPending ||
hasUnifiedMessages
)
}, [
selectedTask,
selectedTaskDetail,
taskState?.messages,
streamHandlers.hasPendingUserMessage,
streamHandlers.isStreaming,
streamHandlers.pendingTaskId,
streamHandlers.localPendingMessage,
])
}
11 changes: 8 additions & 3 deletions frontend/src/features/tasks/hooks/useTaskStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* Handles subscription to state changes and provides convenient accessors.
*/

import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react'
import { taskStateManager, TaskStateData, UnifiedMessage, SyncOptions } from '../state'

export interface UseTaskStateMachineResult {
Expand All @@ -36,8 +36,13 @@ export function useTaskStateMachine(
): UseTaskStateMachineResult {
const [state, setState] = useState<TaskStateData | null>(null)

// Check if manager is initialized
const isInitialized = taskStateManager.isInitialized()
// Reactively track manager initialization via useSyncExternalStore
const isInitialized = useSyncExternalStore(
taskStateManager.subscribeInit,
taskStateManager.getInitialized,
// SSR snapshot: always false on server
Comment on lines +40 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1. Find and read the TaskStateManager.ts file to see method definitions
echo "=== TaskStateManager.ts method definitions ==="
rg -nP --type=ts -A5 '(subscribeInit|getInitialized)' frontend/src/features/tasks/state/TaskStateManager.ts | head -50

# 2. Verify the actual callsite in useTaskStateMachine.ts
echo ""
echo "=== useTaskStateMachine.ts useSyncExternalStore usage ==="
rg -nP --type=ts -B2 -A6 'useSyncExternalStore' frontend/src/features/tasks/hooks/useTaskStateMachine.ts

# 3. Check if TaskStateManager is a class and how subscribeInit/getInitialized are defined
echo ""
echo "=== TaskStateManager class structure ==="
rg -nP --type=ts -B5 'class TaskStateManager' frontend/src/features/tasks/state/TaskStateManager.ts | head -20

Repository: wecode-ai/Wegent

Length of output: 1372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check for other usages of these methods to see if this is the only problem site
echo "=== Other usages of taskStateManager.subscribeInit and getInitialized ==="
rg -nP --type=ts 'taskStateManager\.(subscribeInit|getInitialized)' frontend/src/features/tasks

echo ""
echo "=== Verify taskStateManager is an instance (singleton) ==="
rg -nP --type=ts 'export.*taskStateManager' frontend/src/features/tasks/state

Repository: wecode-ai/Wegent

Length of output: 603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify that subscribeInit actually uses 'this' context
echo "=== Full subscribeInit method body ==="
rg -nP --type=ts -A8 'subscribeInit\(listener' frontend/src/features/tasks/state/TaskStateManager.ts

echo ""
echo "=== Check if any of these methods are arrow functions ==="
rg -nP --type=ts '(subscribeInit|getInitialized)\s*=' frontend/src/features/tasks/state/TaskStateManager.ts

echo ""
echo "=== Verify this.initListeners and this.deps exist ==="
rg -nP --type=ts '(this\.initListeners|this\.deps)\s*' frontend/src/features/tasks/state/TaskStateManager.ts | head -10

Repository: wecode-ai/Wegent

Length of output: 417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check the full method signatures and their dependencies on 'this'
echo "=== TaskStateManager class fields ==="
rg -nP --type=ts -B2 'class TaskStateManagerImpl' frontend/src/features/tasks/state/TaskStateManager.ts | head -30

echo ""
echo "=== getInitialized full method ==="
rg -nP --type=ts -A3 'getInitialized\(\)' frontend/src/features/tasks/state/TaskStateManager.ts

echo ""
echo "=== Check where initListeners and deps are initialized ==="
rg -nP --type=ts '(this\.initListeners|this\.deps)\s*=' frontend/src/features/tasks/state/TaskStateManager.ts | head -5

Repository: wecode-ai/Wegent

Length of output: 411


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Let's verify how useSyncExternalStore calls these functions to understand the binding issue
echo "=== React useSyncExternalStore API check ==="
echo "useSyncExternalStore signature: subscribe(listener), getSnapshot(), getServerSnapshot()"
echo ""
echo "The issue is that when you pass taskStateManager.subscribeInit directly,"
echo "React will call it as: subscribeInit(listener) without 'this' context"
echo ""
echo "Let's verify the method needs 'this' by checking all 'this' usages:"
rg -nP --type=ts 'this\.(initListeners|deps)' frontend/src/features/tasks/state/TaskStateManager.ts

Repository: wecode-ai/Wegent

Length of output: 726


Bind TaskStateManager methods before passing to useSyncExternalStore

Lines 41–42 pass unbound instance methods. Both subscribeInit and getInitialized depend on this context (this.initListeners and this.deps). When React invokes them without the instance, this becomes undefined, causing a runtime error.

Proposed fix
  const isInitialized = useSyncExternalStore(
-   taskStateManager.subscribeInit,
-   taskStateManager.getInitialized,
+   listener => taskStateManager.subscribeInit(listener),
+   () => taskStateManager.getInitialized(),
    // SSR snapshot: always false on server
    () => false
  )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isInitialized = useSyncExternalStore(
taskStateManager.subscribeInit,
taskStateManager.getInitialized,
// SSR snapshot: always false on server
const isInitialized = useSyncExternalStore(
listener => taskStateManager.subscribeInit(listener),
() => taskStateManager.getInitialized(),
// SSR snapshot: always false on server
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/features/tasks/hooks/useTaskStateMachine.ts` around lines 40 -
43, The code passes unbound instance methods taskStateManager.subscribeInit and
taskStateManager.getInitialized into useSyncExternalStore, causing `this` to be
lost; fix by passing bound functions (e.g., bind subscribeInit and
getInitialized to taskStateManager or wrap them in arrow functions that call the
methods on taskStateManager) so that useSyncExternalStore receives
properly-bound callbacks (ensure the same change is applied for the subscriber,
getter, and the SSR snapshot argument if applicable) and reference the methods
by name (subscribeInit, getInitialized) when making the change.

() => false
)

// Subscribe to state changes
// Subscribe to state changes
Expand Down
Loading
Loading