merge: resolve conflicts with main (ExtensionStoreView, InstallButton)

Merged new fields from main (fetchCliStatus, cliStatusLoading,
openDashboard, authMissing, disableReason) into our useShallow
selectors.
This commit is contained in:
Artem Rootman 2026-04-05 17:19:41 +00:00
commit f07eb914ae
No known key found for this signature in database
GPG key ID: B7C30676209A822C
8 changed files with 243 additions and 104 deletions

View file

@ -27,8 +27,6 @@ import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
@ -691,10 +689,6 @@ export class TeamDataService {
);
mark('resolveMembers');
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
mark('enrichBranches');
mark('syncComments');
let processes: TeamProcess[] = [];
@ -714,9 +708,9 @@ export class TeamDataService {
'sentMessages'
)} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince(
'kanbanGc'
)} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince(
'enrichBranches'
)} syncComments=${msSince('syncComments')} processes=${msSince('processes')}`
)} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince(
'processes'
)}`
);
}
@ -811,68 +805,6 @@ export class TeamDataService {
}
}
/**
* Enriches members with gitBranch when their cwd differs from the lead's.
* Mutates members in-place for efficiency (called right after resolveMembers).
*/
private async enrichMemberBranches(
members: ResolvedTeamMember[],
config: TeamConfig
): Promise<void> {
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
const leadEntry = config.members?.find((m) => isLeadMember(m));
const leadCwd = leadEntry?.cwd ?? config.projectPath;
if (!leadCwd) return;
const withTimeout = async <T>(p: Promise<T>, ms: number): Promise<T> => {
let timer: NodeJS.Timeout | null = null;
try {
return await Promise.race([
p,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('timeout')), ms);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
};
let leadBranch: string | null = null;
try {
// Git can hang on some Windows setups (network drives, locked repos, credential prompts).
// Branch is best-effort; never block team:getData on it.
leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000);
} catch {
// Lead cwd may not be a git repo — skip enrichment entirely
return;
}
const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd);
if (candidates.length === 0) return;
const concurrency = process.platform === 'win32' ? 4 : 8;
for (let i = 0; i < candidates.length; i += concurrency) {
const batch = candidates.slice(i, i + concurrency);
await Promise.all(
batch.map(async (member) => {
if (!member.cwd) return;
try {
const branch = await withTimeout(
gitIdentityResolver.getBranch(path.normalize(member.cwd)),
2000
);
if (branch && branch !== leadBranch) {
member.gitBranch = branch;
}
} catch {
// Member cwd may not be a git repo — skip silently
}
})
);
}
}
/**
* Ensures a member exists in members.meta.json.
* Members can appear in the UI from three sources (see TeamMemberResolver):

View file

@ -44,7 +44,8 @@ export class TeamInboxReader {
return entries
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
.map((name) => name.replace(/\.json$/, ''));
.map((name) => name.replace(/\.json$/, ''))
.filter((name) => name !== '*');
}
async getMessagesFor(teamName: string, member: string): Promise<InboxMessage[]> {

View file

@ -32,6 +32,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const tabId = useTabIdOptional();
const {
fetchPluginCatalog,
fetchCliStatus,
fetchApiKeys,
fetchSkillsCatalog,
mcpBrowse,
@ -40,11 +41,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpBrowseLoading,
skillsLoading,
cliStatus,
cliStatusLoading,
openDashboard,
sessions,
projects,
} = useStore(
useShallow((s) => ({
fetchPluginCatalog: s.fetchPluginCatalog,
fetchCliStatus: s.fetchCliStatus,
fetchApiKeys: s.fetchApiKeys,
fetchSkillsCatalog: s.fetchSkillsCatalog,
mcpBrowse: s.mcpBrowse,
@ -53,6 +57,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
mcpBrowseLoading: s.mcpBrowseLoading,
skillsLoading: s.skillsLoading,
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
openDashboard: s.openDashboard,
sessions: s.sessions,
projects: s.projects,
}))
@ -115,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
void fetchPluginCatalog(projectPath ?? undefined);
}, [fetchPluginCatalog, projectPath]);
useEffect(() => {
void fetchCliStatus();
}, [fetchCliStatus]);
// Fetch MCP installed state on mount
useEffect(() => {
void mcpFetchInstalled(projectPath ?? undefined);
@ -139,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => {
}, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]);
const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
const cliStatusBanner = useMemo(() => {
if (cliStatusLoading || cliStatus === null) {
return (
<div className="bg-surface/70 mx-4 mt-3 flex items-start gap-3 rounded-md border border-border px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-text-secondary" />
<div>
<p className="text-sm font-medium text-text">Checking Claude CLI availability</p>
<p className="mt-0.5 text-xs text-text-muted">
Extensions need Claude CLI to install plugins, run MCP servers, and validate auth.
</p>
</div>
</div>
);
}
if (!cliStatus.installed) {
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">Claude CLI is not available</p>
<p className="mt-0.5 text-xs text-text-muted">
Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to
install it and retry.
</p>
</div>
<Button size="sm" variant="outline" onClick={openDashboard}>
Open Dashboard
</Button>
</div>
);
}
if (!cliStatus.authLoggedIn) {
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-amber-300">Claude CLI needs sign-in</p>
<p className="mt-0.5 text-xs text-text-muted">
Claude CLI was found
{cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin
installs are disabled until you sign in from the Dashboard.
</p>
</div>
<Button size="sm" variant="outline" onClick={openDashboard}>
Open Dashboard
</Button>
</div>
);
}
return (
<div className="mx-4 mt-3 flex items-start gap-3 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-emerald-300" />
<div>
<p className="text-sm font-medium text-emerald-300">Claude CLI is ready</p>
<p className="mt-0.5 text-xs text-text-muted">
Plugins can be installed from this page
{cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}.
</p>
</div>
</div>
);
}, [cliStatus, cliStatusLoading, openDashboard]);
// Browser mode guard
if (!api.plugins && !api.mcpRegistry && !api.skills) {
@ -156,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
return (
<TooltipProvider>
<div className="flex flex-1 flex-col overflow-hidden">
{cliStatusBanner}
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4">

View file

@ -37,9 +37,25 @@ export const InstallButton = ({
size = 'sm',
errorMessage,
}: InstallButtonProps) => {
const cliStatus = useStore(useShallow((s) => s.cliStatus));
const cliMissing = cliStatus !== null && !cliStatus.installed;
const isDisabled = disabled || cliMissing;
const { cliStatus, cliStatusLoading } = useStore(
useShallow((s) => ({
cliStatus: s.cliStatus,
cliStatusLoading: s.cliStatusLoading,
}))
);
const cliUnknown = cliStatus === null;
const cliMissing = cliStatus?.installed === false;
const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn;
const disableReason = cliStatusLoading
? 'Checking Claude CLI status...'
: cliUnknown
? 'Checking Claude CLI availability...'
: cliMissing
? 'Claude CLI required. Install it from the Dashboard.'
: authMissing
? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.'
: null;
const isDisabled = disabled || Boolean(disableReason);
const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null);
useEffect(() => {
@ -92,23 +108,30 @@ export const InstallButton = ({
</Button>
);
if (errorMessage) {
const tooltipMessage = disableReason ?? errorMessage;
if (tooltipMessage) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{retryButton}</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-red-300">{errorMessage}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex max-w-64 flex-col items-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{retryButton}</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 text-red-300">{tooltipMessage}</TooltipContent>
</Tooltip>
</TooltipProvider>
{errorMessage && !disableReason ? (
<p className="text-right text-[11px] leading-4 text-red-300">{errorMessage}</p>
) : null}
</div>
);
}
return retryButton;
}
// idle — wrap in tooltip when CLI missing
// idle — wrap in tooltip when install is unavailable
const button = isInstalled ? (
<Button
size={size}
@ -139,14 +162,14 @@ export const InstallButton = ({
</Button>
);
if (cliMissing) {
if (disableReason) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0}>{button}</span>
</TooltipTrigger>
<TooltipContent>Claude CLI required</TooltipContent>
<TooltipContent className="max-w-64">{disableReason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);

View file

@ -98,7 +98,7 @@ export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.J
</p>
{/* Footer: author + version + install button */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-3 text-xs text-text-muted">
<span className="truncate">{plugin.author?.name ?? 'Unknown author'}</span>
{plugin.version && (

View file

@ -777,17 +777,69 @@ export const TeamDetailView = ({
};
}, [projectId]);
// Live git branch polling for the team's project path
// Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
const branchSyncPaths = useMemo(
() => (teamProjectPath ? [teamProjectPath] : []),
[teamProjectPath]
);
// Live branch sync now uses main-side background tracking instead of renderer polling.
const leadProjectPath = useMemo(() => {
const explicitLeadPath = data?.members.find((member) => isLeadMember(member))?.cwd?.trim();
return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath;
}, [data?.members, teamProjectPath]);
const branchSyncPaths = useMemo(() => {
const uniquePaths = new Map<string, string>();
const addPath = (candidate: string | null | undefined): void => {
const trimmed = candidate?.trim();
if (!trimmed) return;
const key = normalizePath(trimmed);
if (!key || uniquePaths.has(key)) return;
uniquePaths.set(key, trimmed);
};
addPath(leadProjectPath);
for (const member of data?.members ?? []) {
addPath(member.cwd);
}
return Array.from(uniquePaths.values());
}, [data?.members, leadProjectPath]);
useBranchSync(branchSyncPaths, { live: true });
const leadBranch = useStore((s) =>
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
const trackedBranches = useStore(
useShallow((s) =>
Object.fromEntries(
branchSyncPaths.map((projectPath) => {
const normalizedPath = normalizePath(projectPath);
return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const;
})
)
)
);
const leadBranch = leadProjectPath
? (trackedBranches[normalizePath(leadProjectPath)] ?? null)
: null;
const membersWithLiveBranches = useMemo(() => {
if (!data) return [];
return data.members.map((member) => {
const memberPath = member.cwd?.trim();
const nextGitBranch =
memberPath && !isLeadMember(member) && leadBranch !== null
? (() => {
const branch = trackedBranches[normalizePath(memberPath)] ?? null;
return branch && branch !== leadBranch ? branch : undefined;
})()
: undefined;
if (member.gitBranch === nextGitBranch) {
return member;
}
const nextMember: ResolvedTeamMember = { ...member };
if (nextGitBranch) {
nextMember.gitBranch = nextGitBranch;
} else {
delete nextMember.gitBranch;
}
return nextMember;
});
}, [data, leadBranch, trackedBranches]);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessionIds = useMemo(() => {
@ -858,7 +910,7 @@ export const TeamDetailView = ({
return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]);
const activeMembers = useStableActiveMembers(data?.members);
const activeMembers = useStableActiveMembers(membersWithLiveBranches);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
@ -987,12 +1039,12 @@ export const TeamDetailView = ({
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => {
if (!pendingMemberProfile || !data) return;
const member = data.members.find((m) => m.name === pendingMemberProfile);
const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile);
if (member) {
setSelectedMember(member);
}
useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, data]);
}, [pendingMemberProfile, membersWithLiveBranches]);
const handleDeleteTask = useCallback(
(taskId: string) => {
@ -1610,7 +1662,7 @@ export const TeamDetailView = ({
}
>
<MemberList
members={data.members}
members={membersWithLiveBranches}
memberTaskCounts={memberTaskCounts}
taskMap={taskMap}
pendingRepliesByMember={pendingRepliesByMember}
@ -1977,7 +2029,7 @@ export const TeamDetailView = ({
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
currentMembers={data.members.filter((m) => !isLeadMember(m))}
currentMembers={membersWithLiveBranches.filter((m) => !isLeadMember(m))}
projectPath={data.config.projectPath}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}
@ -1986,8 +2038,8 @@ export const TeamDetailView = ({
<AddMemberDialog
open={addMemberDialogOpen}
teamName={teamName}
existingNames={data.members.map((m) => m.name)}
existingMembers={data.members}
existingNames={membersWithLiveBranches.map((m) => m.name)}
existingMembers={membersWithLiveBranches}
projectPath={data.config.projectPath}
adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)}
@ -2070,7 +2122,7 @@ export const TeamDetailView = ({
mode="launch"
open={launchDialogOpen}
teamName={teamName}
members={data?.members ?? []}
members={membersWithLiveBranches}
defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError}
clearProvisioningError={clearProvisioningError}

View file

@ -4,6 +4,7 @@
*/
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import type { AppState } from '../types';
import type {
@ -143,6 +144,10 @@ function getSkillsCatalogKey(projectPath?: string): string {
/** Duration to show "success" state before returning to idle */
const SUCCESS_DISPLAY_MS = 2_000;
const CLI_AUTH_REQUIRED_MESSAGE =
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
const CLI_STATUS_UNKNOWN_MESSAGE =
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
set,
@ -552,8 +557,36 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
installPlugin: async (request: PluginInstallRequest) => {
if (!api.plugins) return;
const preflightState = get();
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
try {
await preflightState.fetchCliStatus();
} catch {
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
}
}
const cliStatus = get().cliStatus;
const preflightError =
cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
if (preflightError) {
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
}));
return;
}
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
}));
try {

View file

@ -262,6 +262,17 @@ describe('extensionsSlice', () => {
describe('installPlugin', () => {
it('sets progress to pending then success', async () => {
store.setState({
cliStatus: {
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authMethod: 'oauth_token',
},
});
const plugins = [makePlugin({ pluginId: 'a@m' })];
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
@ -276,6 +287,17 @@ describe('extensionsSlice', () => {
});
it('sets progress to error on failure', async () => {
store.setState({
cliStatus: {
installed: true,
installedVersion: '1.0.0',
binaryPath: '/usr/local/bin/claude',
latestVersion: '1.0.0',
updateAvailable: false,
authLoggedIn: true,
authMethod: 'oauth_token',
},
});
(api.plugins!.install as ReturnType<typeof vi.fn>).mockResolvedValue({
state: 'error',
error: 'Not found',