From 32bd3f2ca0dce35990c05e4b15e4e0028004041f Mon Sep 17 00:00:00 2001 From: Erich Woo Date: Fri, 10 Jul 2026 00:39:28 -0400 Subject: [PATCH] fix(agentex-ui): hydrate agent from task on deep-link, drop inaccessible task_id A URL carrying only task_id (a shared or hand-built link) left the agent pill empty, and a task the caller can't see showed a phantom task view. - ChatView fills agent_name from the task's own (access-checked) agent when the URL omits it, and drops task_id on a 403/404 retrieve instead of leaving a phantom. - agentex-ui-root no longer restores the last-used agent from storage while a task is open, so it can't override the task's agent. Access is unchanged: the BFF attaches credentials server-side and account_id is only a selector, so this can only surface tasks/agents the caller was already authorized to load. Co-Authored-By: Claude Opus 4.8 --- agentex-ui/components/agentex-ui-root.tsx | 8 +++- .../components/primary-content/chat-view.tsx | 37 ++++++++++++++++++- agentex-ui/hooks/use-tasks.ts | 9 +++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/agentex-ui/components/agentex-ui-root.tsx b/agentex-ui/components/agentex-ui-root.tsx index 42e1e548..86eb681c 100644 --- a/agentex-ui/components/agentex-ui-root.tsx +++ b/agentex-ui/components/agentex-ui-root.tsx @@ -63,17 +63,21 @@ export function AgentexUIRoot() { // than bouncing a possibly-valid deep link to the home grid. const couldNotDetermine = !agentInList && isAgentByNameError; - if (agentName && !isAgentValid && !couldNotDetermine) { + // Only validate/clear on the home surface. With a task open its agent wins (hydrated in + // ChatView) even if non-Ready, so this doesn't ping-pong against that restore. + if (agentName && !taskID && !isAgentValid && !couldNotDetermine) { updateParams({ [SearchParamKey.AGENT_NAME]: null }); setLocalAgentName(undefined); } - if (!agentName && localAgentName) { + // With a task open, its agent wins (hydrated in ChatView) — don't restore from storage. + if (!agentName && !taskID && localAgentName) { updateParams({ [SearchParamKey.AGENT_NAME]: localAgentName }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ sgpAccountID, + taskID, isAgentsFetching, isAgentByNameFetching, isAgentByNameError, diff --git a/agentex-ui/components/primary-content/chat-view.tsx b/agentex-ui/components/primary-content/chat-view.tsx index 344338a4..8b764793 100644 --- a/agentex-ui/components/primary-content/chat-view.tsx +++ b/agentex-ui/components/primary-content/chat-view.tsx @@ -1,5 +1,6 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; +import { APIError } from 'agentex'; import { motion } from 'framer-motion'; import { TaskProvider, useAgentexClient } from '@/components/providers'; @@ -9,6 +10,7 @@ import { SearchParamKey, useSafeSearchParams, } from '@/hooks/use-safe-search-params'; +import { useTask } from '@/hooks/use-tasks'; import { TaskHeader } from '../task-header/task-header'; @@ -29,10 +31,41 @@ export function ChatView({ }: ChatViewProps) { const { agentexClient } = useAgentexClient(); const { data: agents = [] } = useAgents(agentexClient); - const { updateParams } = useSafeSearchParams(); + const { agentName, updateParams } = useSafeSearchParams(); + const { + data: task, + isError: isTaskError, + error: taskError, + } = useTask({ agentexClient, taskId: taskID }); const headerRef = useRef(null); + // Deep links may carry only task_id — fill the agent pill from the task's own agent. + const taskAgentName = task?.agents?.[0]?.name; + useEffect(() => { + if (agentName || !taskAgentName) return; + updateParams({ [SearchParamKey.AGENT_NAME]: taskAgentName }, true); + }, [agentName, taskAgentName, updateParams]); + + // Drop a task_id the client can't use: a 4xx means it's bad/off-limits (400/403/404/422). + // 401 is handled by the session refresh + login redirect, and 429/5xx are transient. + useEffect(() => { + const status = taskError instanceof APIError ? taskError.status : undefined; + if ( + isTaskError && + status !== undefined && + status >= 400 && + status < 500 && + status !== 401 && + status !== 429 + ) { + updateParams( + { [SearchParamKey.TASK_ID]: null, [SearchParamKey.AGENT_NAME]: null }, + true + ); + } + }, [isTaskError, taskError, updateParams]); + const handleSelectAgent = useCallback( (agentName: string | undefined) => { updateParams({ diff --git a/agentex-ui/hooks/use-tasks.ts b/agentex-ui/hooks/use-tasks.ts index 95228025..ff32d998 100644 --- a/agentex-ui/hooks/use-tasks.ts +++ b/agentex-ui/hooks/use-tasks.ts @@ -1,4 +1,5 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { APIError } from 'agentex'; import type AgentexSDK from 'agentex'; import type { @@ -42,6 +43,14 @@ export function useTask({ }); }, enabled: !!taskId, + // A 4xx (bad/forbidden task_id) is definitive — fail fast so a deep-link denial clears on + // the first response instead of after retry backoff. Still retry transient 5xx/network. + retry: (failureCount, error) => + !( + error instanceof APIError && + error.status >= 400 && + error.status < 500 + ) && failureCount < 3, }); }