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.
+
+
{
+ e.stopPropagation();
+ onRestartTeam();
+ }}
+ >
+
+ Restart team
+
+
+
+ ) : 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,