Merge branch 'main' of https://github.com/777genius/claude_agent_teams_ui into fix/remove-stale-oauth-token-injection

This commit is contained in:
iliya 2026-03-04 18:59:39 +02:00
commit 1ad27c23c9
8 changed files with 146 additions and 47 deletions

View file

@ -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) => {

View file

@ -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}

View file

@ -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}

View file

@ -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}
/>
);
})}

View file

@ -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;

View file

@ -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) {

View file

@ -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 };

View file

@ -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,