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:
parent
2b3e0cfc2d
commit
527835320f
8 changed files with 146 additions and 47 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<AttachmentDisplay
|
||||
teamName={teamName}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ interface ActivityTimelineProps {
|
|||
onMessageVisible?: (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;
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const reportedRef = useRef(false);
|
||||
|
|
@ -101,6 +105,7 @@ const MessageRowWithObserver = ({
|
|||
onCreateTask={onCreateTask}
|
||||
onReply={onReply}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ interface LaunchTeamDialogProps {
|
|||
members: ResolvedTeamMember[];
|
||||
defaultProjectPath?: string;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError?: () => void;
|
||||
activeTeams?: ActiveTeamRef[];
|
||||
onClose: () => void;
|
||||
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,22 @@ const logger = createLogger('Store:sessionDetail');
|
|||
const sessionRefreshGeneration = new Map<string, number>();
|
||||
const sessionRefreshInFlight = 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 = '';
|
||||
|
||||
import { getAllTabs } from '../utils/paneHelpers';
|
||||
|
|
@ -148,7 +163,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
|
||||
// Fetch full session detail with chunks and subagents
|
||||
fetchSessionDetail: async (projectId: string, sessionId: string, tabId?: string) => {
|
||||
const requestGeneration = ++sessionDetailFetchGeneration;
|
||||
const requestGeneration = incrementTabGeneration(tabId);
|
||||
set({
|
||||
sessionDetailLoading: true,
|
||||
sessionDetailError: null,
|
||||
|
|
@ -172,7 +187,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
}
|
||||
try {
|
||||
const detail = await api.getSessionDetail(projectId, sessionId);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -217,7 +232,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
let claudeMdTokenData: Record<string, ClaudeMdFileInfo> = {};
|
||||
try {
|
||||
claudeMdTokenData = await api.readClaudeMdFiles(projectRoot);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -259,7 +274,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
}
|
||||
})
|
||||
);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +371,7 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
}
|
||||
})
|
||||
);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
for (const { filePath, fileInfo } of mentionedFileResults) {
|
||||
|
|
@ -378,25 +393,16 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
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();
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
const activeTab = currentState.getActiveTab();
|
||||
const stillViewingSession =
|
||||
currentState.selectedSessionId === sessionId ||
|
||||
(activeTab?.type === 'session' &&
|
||||
activeTab.sessionId === sessionId &&
|
||||
activeTab.projectId === projectId);
|
||||
if (!stillViewingSession) {
|
||||
set({
|
||||
sessionDetailLoading: false,
|
||||
conversationLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const existingTab = findTabBySession(currentState.openTabs, sessionId);
|
||||
|
||||
// Update tab label across ALL panes (not just focused pane's openTabs)
|
||||
const allTabsForLabel = getAllTabs(currentState.paneLayout);
|
||||
const existingTab = findTabBySession(allTabsForLabel, sessionId);
|
||||
if (existingTab && detail) {
|
||||
const newLabel = detail.session.firstMessage
|
||||
? truncateLabel(detail.session.firstMessage)
|
||||
|
|
@ -404,18 +410,6 @@ export const createSessionDetailSlice: StateCreator<AppState, [], [], SessionDet
|
|||
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
|
||||
if (tabId && conversation?.items && get().appConfig?.general?.autoExpandAIGroups) {
|
||||
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) {
|
||||
const prev = get().tabSessionData;
|
||||
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) {
|
||||
logger.error('fetchSessionDetail error:', error);
|
||||
if (requestGeneration !== sessionDetailFetchGeneration) {
|
||||
if (!isCurrentTabGeneration(requestGeneration, tabId)) {
|
||||
return;
|
||||
}
|
||||
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
|
||||
cleanupTabSessionData: (tabId: string) => {
|
||||
tabFetchGeneration.delete(tabId);
|
||||
const prev = get().tabSessionData;
|
||||
if (!(tabId in prev)) return;
|
||||
const next = { ...prev };
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export interface TeamSlice {
|
|||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
activeProvisioningRunId: string | null;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError: () => void;
|
||||
kanbanFilterQuery: string | null;
|
||||
provisioningProgressUnsubscribe: (() => void) | null;
|
||||
fetchBranches: (paths: string[]) => Promise<void>;
|
||||
|
|
@ -353,6 +354,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
leadActivityByTeam: {},
|
||||
activeProvisioningRunId: null,
|
||||
provisioningError: null,
|
||||
clearProvisioningError: () => set({ provisioningError: null }),
|
||||
kanbanFilterQuery: null,
|
||||
globalTaskDetail: null,
|
||||
pendingReviewRequest: null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue