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.
This commit is contained in:
iliya 2026-03-04 18:57:13 +02:00
parent 2b3e0cfc2d
commit 527835320f
8 changed files with 146 additions and 47 deletions

View file

@ -210,6 +210,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateMemberRole, updateMemberRole,
launchTeam, launchTeam,
provisioningError, provisioningError,
clearProvisioningError,
isTeamProvisioning, isTeamProvisioning,
leadActivityByTeam, leadActivityByTeam,
refreshTeamData, refreshTeamData,
@ -247,6 +248,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
updateMemberRole: s.updateMemberRole, updateMemberRole: s.updateMemberRole,
launchTeam: s.launchTeam, launchTeam: s.launchTeam,
provisioningError: s.provisioningError, provisioningError: s.provisioningError,
clearProvisioningError: s.clearProvisioningError,
isTeamProvisioning: Object.values(s.provisioningRuns).some( isTeamProvisioning: Object.values(s.provisioningRuns).some(
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
), ),
@ -1285,6 +1287,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSendDialogOpen(true); setSendDialogOpen(true);
}} }}
onMessageVisible={handleMessageVisible} onMessageVisible={handleMessageVisible}
onRestartTeam={() => setLaunchDialogOpen(true)}
onTaskIdClick={(taskId) => { onTaskIdClick={(taskId) => {
const task = taskMap.get(taskId); const task = taskMap.get(taskId);
if (task) setSelectedTask(task); if (task) setSelectedTask(task);
@ -1485,6 +1488,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
members={data?.members ?? []} members={data?.members ?? []}
defaultProjectPath={data.config.projectPath} defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError} provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
activeTeams={activeTeamsForLaunch} activeTeams={activeTeamsForLaunch}
onClose={() => setLaunchDialogOpen(false)} onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => { onLaunch={async (request) => {

View file

@ -218,16 +218,23 @@ export const TeamListView = (): React.JSX.Element => {
activeProjectId: s.activeProjectId, activeProjectId: s.activeProjectId,
})) }))
); );
const { connectionMode, createTeam, provisioningError, provisioningRuns, leadActivityByTeam } = const {
useStore( connectionMode,
useShallow((s) => ({ createTeam,
connectionMode: s.connectionMode, provisioningError,
createTeam: s.createTeam, clearProvisioningError,
provisioningError: s.provisioningError, provisioningRuns,
provisioningRuns: s.provisioningRuns, leadActivityByTeam,
leadActivityByTeam: s.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'; const canCreate = electronMode && connectionMode === 'local';
// Fetch alive teams on mount and when teams list changes // Fetch alive teams on mount and when teams list changes
@ -493,6 +500,7 @@ export const TeamListView = (): React.JSX.Element => {
open={showCreateDialog} open={showCreateDialog}
canCreate={canCreate} canCreate={canCreate}
provisioningError={provisioningError} provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}
existingTeamNames={teams.map((t) => t.teamName)} existingTeamNames={teams.map((t) => t.teamName)}
activeTeams={activeTeams} activeTeams={activeTeams}
initialData={copyData ?? undefined} initialData={copyData ?? undefined}

View file

@ -22,7 +22,7 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector'; 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'; import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -44,6 +44,8 @@ interface ActivityItemProps {
onReply?: (message: InboxMessage) => void; onReply?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */ /** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void; 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. */ /** When true, apply a subtle lighter background for zebra-striped lists. */
zebraShade?: boolean; zebraShade?: boolean;
} }
@ -132,6 +134,16 @@ function getSystemMessageLabel(text: string): string | null {
return 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 // Full message card — left colored border, name badge, collapsible content
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -174,6 +186,7 @@ export const ActivityItem = ({
onCreateTask, onCreateTask,
onReply, onReply,
onTaskIdClick, onTaskIdClick,
onRestartTeam,
zebraShade, zebraShade,
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
@ -188,6 +201,8 @@ export const ActivityItem = ({
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
// Highlight messages containing API errors // Highlight messages containing API errors
const isApiError = message.text.includes('API Error'); 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 // Never collapse rate limit messages as noise — they must be visible
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null; const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
@ -449,6 +464,30 @@ export const ActivityItem = ({
{summaryText} {summaryText}
</p> </p>
) : null} ) : null}
{/* Auth error recovery action */}
{isAuthError && onRestartTeam ? (
<div className="mt-2 flex items-start gap-2 rounded border border-red-500/30 bg-red-500/5 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-400" />
<div className="flex-1 space-y-1.5">
<p className="text-[11px] leading-relaxed text-red-300/90">
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.
</p>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={(e) => {
e.stopPropagation();
onRestartTeam();
}}
>
<RefreshCw size={11} />
Restart team
</button>
</div>
</div>
) : null}
{message.attachments?.length && message.messageId ? ( {message.attachments?.length && message.messageId ? (
<AttachmentDisplay <AttachmentDisplay
teamName={teamName} teamName={teamName}

View file

@ -23,6 +23,8 @@ interface ActivityTimelineProps {
onMessageVisible?: (message: InboxMessage) => void; onMessageVisible?: (message: InboxMessage) => void;
/** Called when a task ID link (e.g. #10) is clicked in message text. */ /** Called when a task ID link (e.g. #10) is clicked in message text. */
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
/** Called when the user clicks "Restart team" on an auth error message. */
onRestartTeam?: () => void;
} }
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
@ -42,6 +44,7 @@ const MessageRowWithObserver = ({
onReply, onReply,
onVisible, onVisible,
onTaskIdClick, onTaskIdClick,
onRestartTeam,
}: { }: {
message: InboxMessage; message: InboxMessage;
teamName: string; teamName: string;
@ -56,6 +59,7 @@ const MessageRowWithObserver = ({
onReply?: (message: InboxMessage) => void; onReply?: (message: InboxMessage) => void;
onVisible?: (message: InboxMessage) => void; onVisible?: (message: InboxMessage) => void;
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
onRestartTeam?: () => void;
}): React.JSX.Element => { }): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const reportedRef = useRef(false);
@ -101,6 +105,7 @@ const MessageRowWithObserver = ({
onCreateTask={onCreateTask} onCreateTask={onCreateTask}
onReply={onReply} onReply={onReply}
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
/> />
</div> </div>
); );
@ -116,6 +121,7 @@ export const ActivityTimeline = ({
onMemberClick, onMemberClick,
onMessageVisible, onMessageVisible,
onTaskIdClick, onTaskIdClick,
onRestartTeam,
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
@ -273,6 +279,7 @@ export const ActivityTimeline = ({
onReply={onReplyToMessage} onReply={onReplyToMessage}
onVisible={onMessageVisible} onVisible={onMessageVisible}
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
/> />
); );
})} })}

View file

@ -72,6 +72,7 @@ interface CreateTeamDialogProps {
open: boolean; open: boolean;
canCreate: boolean; canCreate: boolean;
provisioningError: string | null; provisioningError: string | null;
clearProvisioningError?: () => void;
existingTeamNames: string[]; existingTeamNames: string[];
activeTeams?: ActiveTeamRef[]; activeTeams?: ActiveTeamRef[];
initialData?: TeamCopyData; initialData?: TeamCopyData;
@ -189,6 +190,7 @@ export const CreateTeamDialog = ({
open, open,
canCreate, canCreate,
provisioningError, provisioningError,
clearProvisioningError,
existingTeamNames, existingTeamNames,
activeTeams, activeTeams,
initialData, initialData,
@ -265,6 +267,13 @@ export const CreateTeamDialog = ({
resetUIState(); resetUIState();
}; };
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (open) {
clearProvisioningError?.();
}
}, [open, clearProvisioningError]);
useEffect(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {
return; return;

View file

@ -41,6 +41,7 @@ interface LaunchTeamDialogProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
defaultProjectPath?: string; defaultProjectPath?: string;
provisioningError: string | null; provisioningError: string | null;
clearProvisioningError?: () => void;
activeTeams?: ActiveTeamRef[]; activeTeams?: ActiveTeamRef[];
onClose: () => void; onClose: () => void;
onLaunch: (request: TeamLaunchRequest) => Promise<void>; onLaunch: (request: TeamLaunchRequest) => Promise<void>;
@ -52,6 +53,7 @@ export const LaunchTeamDialog = ({
members, members,
defaultProjectPath, defaultProjectPath,
provisioningError, provisioningError,
clearProvisioningError,
activeTeams, activeTeams,
onClose, onClose,
onLaunch, onLaunch,
@ -101,6 +103,13 @@ export const LaunchTeamDialog = ({
chipDraft.clearChipDraft(); chipDraft.clearChipDraft();
}; };
// Clear stale provisioning error when dialog opens
useEffect(() => {
if (open) {
clearProvisioningError?.();
}
}, [open, clearProvisioningError]);
// Warm up CLI on open // Warm up CLI on open
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {

View file

@ -24,7 +24,22 @@ const logger = createLogger('Store:sessionDetail');
const sessionRefreshGeneration = new Map<string, number>(); const sessionRefreshGeneration = new Map<string, number>();
const sessionRefreshInFlight = new Set<string>(); const sessionRefreshInFlight = new Set<string>();
const sessionRefreshQueued = new Set<string>(); const sessionRefreshQueued = new Set<string>();
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<string, number>();
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 = ''; let agentConfigsCachedForProject = '';
import { getAllTabs } from '../utils/paneHelpers'; import { getAllTabs } from '../utils/paneHelpers';
@ -148,7 +163,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// Fetch full session detail with chunks and subagents // Fetch full session detail with chunks and subagents
fetchSessionDetail: async (projectId: string, sessionId: string, tabId?: string) => { fetchSessionDetail: async (projectId: string, sessionId: string, tabId?: string) => {
const requestGeneration = ++sessionDetailFetchGeneration; const requestGeneration = incrementTabGeneration(tabId);
set({ set({
sessionDetailLoading: true, sessionDetailLoading: true,
sessionDetailError: null, sessionDetailError: null,
@ -172,7 +187,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
try { try {
const detail = await api.getSessionDetail(projectId, sessionId); const detail = await api.getSessionDetail(projectId, sessionId);
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
@ -217,7 +232,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {}; let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
try { try {
claudeMdTokenData = await api.readClaudeMdFiles(projectRoot); claudeMdTokenData = await api.readClaudeMdFiles(projectRoot);
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
} catch (err) { } catch (err) {
@ -259,7 +274,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
}) })
); );
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
@ -356,7 +371,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
}) })
); );
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
for (const { filePath, fileInfo } of mentionedFileResults) { for (const { filePath, fileInfo } of mentionedFileResults) {
@ -378,25 +393,16 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
phaseInfo = phaseResult.phaseInfo; phaseInfo = phaseResult.phaseInfo;
} }
// Update tab label if this session is open in a tab // Update tab label and per-tab data regardless of which tab is active.
// This ensures labels are correct and cached data is ready on tab switch.
const currentState = get(); const currentState = get();
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
const activeTab = currentState.getActiveTab();
const stillViewingSession = // Update tab label across ALL panes (not just focused pane's openTabs)
currentState.selectedSessionId === sessionId || const allTabsForLabel = getAllTabs(currentState.paneLayout);
(activeTab?.type === 'session' && const existingTab = findTabBySession(allTabsForLabel, sessionId);
activeTab.sessionId === sessionId &&
activeTab.projectId === projectId);
if (!stillViewingSession) {
set({
sessionDetailLoading: false,
conversationLoading: false,
});
return;
}
const existingTab = findTabBySession(currentState.openTabs, sessionId);
if (existingTab && detail) { if (existingTab && detail) {
const newLabel = detail.session.firstMessage const newLabel = detail.session.firstMessage
? truncateLabel(detail.session.firstMessage) ? truncateLabel(detail.session.firstMessage)
@ -404,18 +410,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
currentState.updateTabLabel(existingTab.id, newLabel); currentState.updateTabLabel(existingTab.id, newLabel);
} }
set({
sessionDetail: detail,
sessionDetailLoading: false,
conversation,
conversationLoading: false,
visibleAIGroupId: firstAIGroupId,
selectedAIGroup: firstAIGroup,
sessionClaudeMdStats: claudeMdStats,
sessionContextStats: contextStats,
sessionPhaseInfo: phaseInfo,
});
// Auto-expand all AI groups if the setting is enabled // Auto-expand all AI groups if the setting is enabled
if (tabId && conversation?.items && get().appConfig?.general?.autoExpandAIGroups) { if (tabId && conversation?.items && get().appConfig?.general?.autoExpandAIGroups) {
for (const item of conversation.items) { for (const item of conversation.items) {
@ -425,7 +419,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
} }
} }
// Store per-tab session data // Store per-tab session data (always, so tab switch can restore from cache)
if (tabId) { if (tabId) {
const prev = get().tabSessionData; const prev = get().tabSessionData;
set({ set({
@ -446,9 +440,35 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
}, },
}); });
} }
// Only update global state if still viewing this session
const activeTab = currentState.getActiveTab();
const stillViewingSession =
currentState.selectedSessionId === sessionId ||
(activeTab?.type === 'session' &&
activeTab.sessionId === sessionId &&
activeTab.projectId === projectId);
if (stillViewingSession) {
set({
sessionDetail: detail,
sessionDetailLoading: false,
conversation,
conversationLoading: false,
visibleAIGroupId: firstAIGroupId,
selectedAIGroup: firstAIGroup,
sessionClaudeMdStats: claudeMdStats,
sessionContextStats: contextStats,
sessionPhaseInfo: phaseInfo,
});
} else {
set({
sessionDetailLoading: false,
conversationLoading: false,
});
}
} catch (error) { } catch (error) {
logger.error('fetchSessionDetail error:', error); logger.error('fetchSessionDetail error:', error);
if (requestGeneration !== sessionDetailFetchGeneration) { if (!isCurrentTabGeneration(requestGeneration, tabId)) {
return; return;
} }
const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail'; const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail';
@ -709,6 +729,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
// Clean up per-tab session data when tab is closed // Clean up per-tab session data when tab is closed
cleanupTabSessionData: (tabId: string) => { cleanupTabSessionData: (tabId: string) => {
tabFetchGeneration.delete(tabId);
const prev = get().tabSessionData; const prev = get().tabSessionData;
if (!(tabId in prev)) return; if (!(tabId in prev)) return;
const next = { ...prev }; const next = { ...prev };

View file

@ -258,6 +258,7 @@ export interface TeamSlice {
leadActivityByTeam: Record<string, LeadActivityState>; leadActivityByTeam: Record<string, LeadActivityState>;
activeProvisioningRunId: string | null; activeProvisioningRunId: string | null;
provisioningError: string | null; provisioningError: string | null;
clearProvisioningError: () => void;
kanbanFilterQuery: string | null; kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null; provisioningProgressUnsubscribe: (() => void) | null;
fetchBranches: (paths: string[]) => Promise<void>; fetchBranches: (paths: string[]) => Promise<void>;
@ -353,6 +354,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
leadActivityByTeam: {}, leadActivityByTeam: {},
activeProvisioningRunId: null, activeProvisioningRunId: null,
provisioningError: null, provisioningError: null,
clearProvisioningError: () => set({ provisioningError: null }),
kanbanFilterQuery: null, kanbanFilterQuery: null,
globalTaskDetail: null, globalTaskDetail: null,
pendingReviewRequest: null, pendingReviewRequest: null,