From 527835320f7f50a4ebc2159a7e482e2e7b3473b9 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 4 Mar 2026 18:57:13 +0200 Subject: [PATCH] feat: add clearProvisioningError functionality and restart team option - Introduced clearProvisioningError method in team-related components to reset provisioning errors when dialogs open, enhancing user experience. - Added onRestartTeam callback to ActivityItem and ActivityTimeline components for handling authentication errors, allowing users to restart teams directly from error messages. - Updated various components to support the new functionality, improving error handling and team management interactions. --- .../components/team/TeamDetailView.tsx | 4 + src/renderer/components/team/TeamListView.tsx | 28 ++++-- .../components/team/activity/ActivityItem.tsx | 41 +++++++- .../team/activity/ActivityTimeline.tsx | 7 ++ .../team/dialogs/CreateTeamDialog.tsx | 9 ++ .../team/dialogs/LaunchTeamDialog.tsx | 9 ++ .../store/slices/sessionDetailSlice.ts | 93 ++++++++++++------- src/renderer/store/slices/teamSlice.ts | 2 + 8 files changed, 146 insertions(+), 47 deletions(-) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 0e5f6def..f91e7cee 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -210,6 +210,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateMemberRole, launchTeam, provisioningError, + clearProvisioningError, isTeamProvisioning, leadActivityByTeam, refreshTeamData, @@ -247,6 +248,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, provisioningError: s.provisioningError, + clearProvisioningError: s.clearProvisioningError, isTeamProvisioning: Object.values(s.provisioningRuns).some( (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), @@ -1285,6 +1287,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSendDialogOpen(true); }} onMessageVisible={handleMessageVisible} + onRestartTeam={() => setLaunchDialogOpen(true)} onTaskIdClick={(taskId) => { const task = taskMap.get(taskId); if (task) setSelectedTask(task); @@ -1485,6 +1488,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={data?.members ?? []} defaultProjectPath={data.config.projectPath} provisioningError={provisioningError} + clearProvisioningError={clearProvisioningError} activeTeams={activeTeamsForLaunch} onClose={() => setLaunchDialogOpen(false)} onLaunch={async (request) => { diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 09cd83fd..db92b3d4 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -218,16 +218,23 @@ export const TeamListView = (): React.JSX.Element => { activeProjectId: s.activeProjectId, })) ); - const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } = - useStore( - useShallow((s) => ({ - connectionMode: s.connectionMode, - createTeam: s.createTeam, - provisioningError: s.provisioningError, - provisioningRuns: s.provisioningRuns, - leadActivityByTeam: s.leadActivityByTeam, - })) - ); + const { + connectionMode, + createTeam, + provisioningError, + clearProvisioningError, + provisioningRuns, + leadActivityByTeam, + } = useStore( + useShallow((s) => ({ + connectionMode: s.connectionMode, + createTeam: s.createTeam, + provisioningError: s.provisioningError, + clearProvisioningError: s.clearProvisioningError, + provisioningRuns: s.provisioningRuns, + leadActivityByTeam: s.leadActivityByTeam, + })) + ); const canCreate = electronMode && connectionMode === 'local'; // Fetch alive teams on mount and when teams list changes @@ -493,6 +500,7 @@ export const TeamListView = (): React.JSX.Element => { open={showCreateDialog} canCreate={canCreate} provisioningError={provisioningError} + clearProvisioningError={clearProvisioningError} existingTeamNames={teams.map((t) => t.teamName)} activeTeams={activeTeams} initialData={copyData ?? undefined} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index d5e7e654..4605d768 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -22,7 +22,7 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; -import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react'; +import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react'; import { ReplyQuoteBlock } from './ReplyQuoteBlock'; @@ -44,6 +44,8 @@ interface ActivityItemProps { onReply?: (message: InboxMessage) => void; /** Called when a task ID link (e.g. #10) is clicked in message text. */ onTaskIdClick?: (taskId: string) => void; + /** Called when the user clicks "Restart team" on an auth error message. */ + onRestartTeam?: () => void; /** When true, apply a subtle lighter background for zebra-striped lists. */ zebraShade?: boolean; } @@ -132,6 +134,16 @@ function getSystemMessageLabel(text: string): string | null { return null; } +/** Detect authentication/authorization errors that may be resolved by restarting. */ +const AUTH_ERROR_PATTERNS = [ + /OAuth token has expired/i, + /API Error:\s*401/i, + /authentication_error/i, + /Failed to authenticate/i, + /invalid.*api.key/i, + /unauthorized/i, +]; + // --------------------------------------------------------------------------- // Full message card — left colored border, name badge, collapsible content // --------------------------------------------------------------------------- @@ -174,6 +186,7 @@ export const ActivityItem = ({ onCreateTask, onReply, onTaskIdClick, + onRestartTeam, zebraShade, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); @@ -188,6 +201,8 @@ export const ActivityItem = ({ const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); // Highlight messages containing API errors const isApiError = message.text.includes('API Error'); + // Detect auth errors that may be resolved by restarting the team + const isAuthError = isApiError && AUTH_ERROR_PATTERNS.some((p) => p.test(message.text)); // Never collapse rate limit messages as noise — they must be visible const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; @@ -449,6 +464,30 @@ export const ActivityItem = ({ {summaryText}

) : null} + {/* Auth error recovery action */} + {isAuthError && onRestartTeam ? ( +
+ +
+

+ Authentication failed. Restarting the team will refresh the session and may + resolve this issue. If the problem persists, check your API credentials or try + again later. +

+ +
+
+ ) : null} {message.attachments?.length && message.messageId ? ( void; /** Called when a task ID link (e.g. #10) is clicked in message text. */ onTaskIdClick?: (taskId: string) => void; + /** Called when the user clicks "Restart team" on an auth error message. */ + onRestartTeam?: () => void; } const VIEWPORT_THRESHOLD = 0.15; @@ -42,6 +44,7 @@ const MessageRowWithObserver = ({ onReply, onVisible, onTaskIdClick, + onRestartTeam, }: { message: InboxMessage; teamName: string; @@ -56,6 +59,7 @@ const MessageRowWithObserver = ({ onReply?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void; onTaskIdClick?: (taskId: string) => void; + onRestartTeam?: () => void; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -101,6 +105,7 @@ const MessageRowWithObserver = ({ onCreateTask={onCreateTask} onReply={onReply} onTaskIdClick={onTaskIdClick} + onRestartTeam={onRestartTeam} /> ); @@ -116,6 +121,7 @@ export const ActivityTimeline = ({ onMemberClick, onMessageVisible, onTaskIdClick, + onRestartTeam, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); @@ -273,6 +279,7 @@ export const ActivityTimeline = ({ onReply={onReplyToMessage} onVisible={onMessageVisible} onTaskIdClick={onTaskIdClick} + onRestartTeam={onRestartTeam} /> ); })} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 920145e0..b533f78e 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -72,6 +72,7 @@ interface CreateTeamDialogProps { open: boolean; canCreate: boolean; provisioningError: string | null; + clearProvisioningError?: () => void; existingTeamNames: string[]; activeTeams?: ActiveTeamRef[]; initialData?: TeamCopyData; @@ -189,6 +190,7 @@ export const CreateTeamDialog = ({ open, canCreate, provisioningError, + clearProvisioningError, existingTeamNames, activeTeams, initialData, @@ -265,6 +267,13 @@ export const CreateTeamDialog = ({ resetUIState(); }; + // Clear stale provisioning error when dialog opens + useEffect(() => { + if (open) { + clearProvisioningError?.(); + } + }, [open, clearProvisioningError]); + useEffect(() => { if (!open || !canCreate || !launchTeam) { return; diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 8f661629..d675305f 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -41,6 +41,7 @@ interface LaunchTeamDialogProps { members: ResolvedTeamMember[]; defaultProjectPath?: string; provisioningError: string | null; + clearProvisioningError?: () => void; activeTeams?: ActiveTeamRef[]; onClose: () => void; onLaunch: (request: TeamLaunchRequest) => Promise; @@ -52,6 +53,7 @@ export const LaunchTeamDialog = ({ members, defaultProjectPath, provisioningError, + clearProvisioningError, activeTeams, onClose, onLaunch, @@ -101,6 +103,13 @@ export const LaunchTeamDialog = ({ chipDraft.clearChipDraft(); }; + // Clear stale provisioning error when dialog opens + useEffect(() => { + if (open) { + clearProvisioningError?.(); + } + }, [open, clearProvisioningError]); + // Warm up CLI on open useEffect(() => { if (!open) { diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts index 7a1de354..ddba4009 100644 --- a/src/renderer/store/slices/sessionDetailSlice.ts +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -24,7 +24,22 @@ const logger = createLogger('Store:sessionDetail'); const sessionRefreshGeneration = new Map(); const sessionRefreshInFlight = new Set(); const sessionRefreshQueued = new Set(); -let sessionDetailFetchGeneration = 0; +/** + * Per-tab fetch generation counters. Prevents concurrent fetches from different + * tabs from cancelling each other (only same-tab re-fetches are cancelled). + */ +const tabFetchGeneration = new Map(); + +function incrementTabGeneration(tabId?: string): number { + const key = tabId ?? '__global__'; + const gen = (tabFetchGeneration.get(key) ?? 0) + 1; + tabFetchGeneration.set(key, gen); + return gen; +} + +function isCurrentTabGeneration(gen: number, tabId?: string): boolean { + return tabFetchGeneration.get(tabId ?? '__global__') === gen; +} let agentConfigsCachedForProject = ''; import { getAllTabs } from '../utils/paneHelpers'; @@ -148,7 +163,7 @@ export const createSessionDetailSlice: StateCreator { - const requestGeneration = ++sessionDetailFetchGeneration; + const requestGeneration = incrementTabGeneration(tabId); set({ sessionDetailLoading: true, sessionDetailError: null, @@ -172,7 +187,7 @@ export const createSessionDetailSlice: StateCreator = {}; try { claudeMdTokenData = await api.readClaudeMdFiles(projectRoot); - if (requestGeneration !== sessionDetailFetchGeneration) { + if (!isCurrentTabGeneration(requestGeneration, tabId)) { return; } } catch (err) { @@ -259,7 +274,7 @@ export const createSessionDetailSlice: StateCreator { + tabFetchGeneration.delete(tabId); const prev = get().tabSessionData; if (!(tabId in prev)) return; const next = { ...prev }; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index cac578ea..5463311a 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -258,6 +258,7 @@ export interface TeamSlice { leadActivityByTeam: Record; activeProvisioningRunId: string | null; provisioningError: string | null; + clearProvisioningError: () => void; kanbanFilterQuery: string | null; provisioningProgressUnsubscribe: (() => void) | null; fetchBranches: (paths: string[]) => Promise; @@ -353,6 +354,7 @@ export const createTeamSlice: StateCreator = (set, leadActivityByTeam: {}, activeProvisioningRunId: null, provisioningError: null, + clearProvisioningError: () => set({ provisioningError: null }), kanbanFilterQuery: null, globalTaskDetail: null, pendingReviewRequest: null,